One header, queen_client.hpp, namespace queen. Everything is inline, so there is nothing to
link. It has no separately published package version: the header is the version at the repository
tag or commit you check out.
#include "queen_client.hpp"
queen::QueenClient client("http://localhost:6632");Build requirements
| Requirement | Notes |
|---|---|
| C++17 or later | The Makefile builds with -std=c++17 -pthread. |
nlohmann/json |
Included as <json.hpp>. |
cpp-httplib |
Included as <httplib.h>. The header force-defines CPPHTTPLIB_OPENSSL_SUPPORT, so OpenSSL must be available even for plain HTTP. |
threadpool.hpp |
Included as "../server/include/threadpool.hpp", relative to the header’s own directory. Provides astp::ThreadPool, used to run concurrent consumers. |
The relative include means the header expects to sit next to the repository’s server/
directory, or to be compiled with that directory on the include path
(-I../server/include -I../server/vendor, as the shipped Makefile does).
Configuration
struct Retry429Options {
int max_attempts = 0; // 0 = kind-based default
int base_millis = 0; // 0 = 500
int cap_millis = 0; // 0 = 30000
};
struct ClientConfig {
std::vector<std::string> urls;
int timeout_millis = 30000;
int retry_attempts = 3;
int retry_delay_millis = 1000;
std::string load_balancing_strategy = "round-robin";
bool enable_failover = true;
std::string bearer_token;
Retry429Options retry_429;
};Retry429Options covers HTTP 429 only, separately from retry_attempts, and a zero field
means “use the kind-based default”; the defaults, the Retry-After contract and the jitter are
in What the clients do with a 429.
QueenClient
QueenClient(const std::string& url);
QueenClient(const std::vector<std::string>& urls, const ClientConfig& config = ClientConfig());| Method | Signature | Returns |
|---|---|---|
queue |
queue(const std::string& name = "") |
QueueBuilder |
transaction |
transaction() |
TransactionBuilder |
ack |
ack(const json& message, bool status = true, const json& context = json::object()) |
json |
renew |
renew(const json& message_or_lease_id) |
json |
flush_all_buffers |
flush_all_buffers(int deadline_millis = -1) |
void; throws BufferFlushError when the deadline expires with messages still buffered |
get_buffer_stats |
get_buffer_stats() const |
json |
close |
close() |
void (sets the shutdown flag, flushes buffers under a 30 s deadline, cleans up) |
is_shutdown_requested |
is_shutdown_requested() const |
bool (consumer workers poll this) |
get_http_client |
get_http_client() const |
std::shared_ptr<HttpClient> |
ack
message is a message object, a transaction-id string, or a JSON array of either. status is
bool only: true → completed, false → failed. context accepts a group key and an
error key.
partitionId is required on every message object; without it the call throws
std::runtime_error("Message must have partitionId property"). A leaseId is forwarded when
present. One message posts to /api/v1/ack; an array posts to /api/v1/ack/batch.
Return shape:
| Call | Shape |
|---|---|
| success | {"success": true, "result": <broker body>} |
| transport or HTTP failure | {"success": false, "error": "<what()>"} |
| empty array | {"processed": 0, "results": []} |
renew
Accepts a lease-id string, a message object, or an array of either. Lease ids are deduplicated in
insertion order: with multi-partition pop one extend call renews every claimed partition, so
passing the whole message array issues one HTTP call. Returns an array of
{leaseId, success, newExpiresAt} for an array input, or the single element for a scalar input.
QueueBuilder
client.queue(name). Configuration methods return QueueBuilder&; terminal methods are push(),
pop(), consume(), create(), del(), dlq() and flush_buffer().
Addressing
| Method | Default | Notes |
|---|---|---|
namespace_name(const std::string&) |
"" |
Named namespace_name because namespace is a keyword. |
task(const std::string&) |
"" |
Second grouping label. |
partition(const std::string&) |
"Default" |
The ordered lane. Anything else switches pop to the partition-scoped route. |
group(const std::string&) |
"" |
Consumer group. Absent, the broker uses __QUEUE_MODE__. |
Queue lifecycle
| Method | Wire call | Returns |
|---|---|---|
config(const QueueConfig&) |
none | QueueBuilder& |
create() |
POST /api/v1/configure |
json |
del() |
DELETE /api/v1/resources/queues/:queue |
json (named del because delete is a keyword) |
QueueConfig’s snake_case fields (lease_time, retry_limit, delayed_processing,
window_buffer, retention_seconds, completed_retention_seconds, encryption_enabled) map
onto the camelCase wire options via to_json(), with the values listed under
Queue option defaults.
create() always sends the complete set, so calling it without config() applies the struct’s
defaults, including leaseTime: 300, whereas a queue created implicitly by a first push gets a
60-second lease. to_json() emits a fixed key set, so options outside that struct cannot be set
through config(); post to /api/v1/configure yourself when you need one. Since 1.6.0 that
route merges, so a hand-written partial body edits what it names
and leaves the rest alone, and "mode": "replace" is how you ask for the old
reset-everything-else behaviour. This client sends no mode and a complete set either way, so
its own create() behaves as it always has.
Producing
| Method | Notes |
|---|---|
buffer(const BufferOptions&) |
{message_count = 100, time_millis = 1000, max_size = 0, retry_delay_millis = 0}, where 0 means the derived default: a 4 × message_count bound and a 250 ms retry pause. Turns push() into an enqueue that BLOCKS at max_size waiting messages. |
push(const std::vector<json>& payload) |
Returns json. Throws with no queue name. |
Each item may carry data, payload, or be the payload itself. The client mints a UUIDv7
transactionId when the item has none, and sends traceId only when it is a valid UUID. The broker’s push path stores no trace id, so the value is dropped and the message pops back with traceId: null; it is honoured only on the transaction path.
A direct push returns the broker’s per-item array; a buffered push returns
{"buffered": true, "count": n} without sending.
client.queue("orders")
.partition("customer-42")
.push({{{"data", {{"orderId", 8891}}}}});Consuming
| Method | pop() default |
consume() default |
Notes |
|---|---|---|---|
batch(int) |
broker-sized | broker-sized | Total message budget for the call, shared across every partition it claims. Left unset the broker sizes it (pop autopilot); the pre-1.2 client default of 1 comes back with autopilot off. batch(0) reads as unset. |
partitions(int) |
broker-sized | broker-sized | Claim up to N partitions per call; all share one leaseId. Left unset the broker sizes it (pop autopilot). Sent verbatim when set, 1 included, because a pinned width is a decision the broker must not widen; with autopilot off it is sent only when > 1. |
autopilot(bool) |
true |
true |
Broker-side pop sizing for the knobs above that you did not set. On by default; false restores the pre-1.2 client defaults (batch 1, partitions 1) and sends no autopilot parameter. QUEEN_SDK_POP_AUTOPILOT=off does the same for a whole process, read once when the client is built. Setting both knobs leaves nothing to decide, so no parameter is sent then either. See pop autopilot. Requires broker >= 1.2; an older one applies its own defaults (batch 200, partitions 1) to the omitted knobs, which is a sizing difference and nothing more. |
wait(bool) |
true |
true |
Long-poll. |
| (no setter) | 30000 |
30000 |
The long-poll deadline is fixed. The client adds 5 s of slack when waiting. |
auto_ack(bool) |
true |
true |
See the warning. |
subscription_mode(const std::string&) |
"" |
"" |
all | new. |
subscription_from(const std::string&) |
"" |
"" |
"now" or an ISO timestamp. |
conflation(bool enabled = true) |
false |
false |
Last-value delivery: the pop returns only each partition’s newest visible message and retires the rest. Sent only when true. A property of the consumer GROUP, stored on its first registration; see conflation. Requires broker >= 1.1.0; against an older one the call raises rather than draining the backlog quietly. |
concurrency(int) |
n/a | 1 |
consume() only. Thread-pool workers. |
limit(int) |
n/a | 0 (unlimited) |
consume() only. |
idle_millis(int) |
n/a | 0 (no timeout) |
consume() only. |
renew_lease(bool, int interval_millis = 60000) |
n/a | false |
consume() only. See the note below. |
each() |
n/a | batch mode | consume() only. One message per handler call. |
pop()
json pop() returns the messages array, or an empty array. Route selection: queue plus non-Default
partition uses /api/v1/pop/queue/:queue/partition/:partition; queue alone uses
/api/v1/pop/queue/:queue; namespace or task alone uses /api/v1/pop. None of the three throws.
pop_result()
PopResult pop_result() returns {messages, autopilot}. It is the same call as pop() and returns the messages plus the broker’s account of how it sized the claim: partitions, batch, and an optional wait_millis pacing hint the consume loop honours in place of its own delay between empty polls. autopilot.present is false when the pop did not engage autopilot, when the broker is older than 1.2, or when the answer was a bodiless 204, which has no body to carry an echo. See pop autopilot.
consume()
void consume(std::function<void(const json&)> handler,
std::atomic<bool>* stop_signal = nullptr);Blocks until every worker stops. The handler receives one message in each() mode and the whole
messages array otherwise. Workers stop when *stop_signal becomes true, when
is_shutdown_requested() is set by close(), on the limit, or on the idle timeout.
std::atomic<bool> stop{false};
client.queue("orders").group("workers").batch(10).each()
.consume([](const queen::json& msg) { /* … */ }, &stop);Worker loop behaviour:
- A handler exception with
auto_ackon nacks the message and the worker continues. - A 429 that escapes the HTTP layer’s own retry sleeps for
Retry-After(or 1 s) and continues. - A timeout while waiting, or a connection failure, retries after a short sleep.
- A 403 stops that worker and is re-thrown to the caller of
consume()once every worker has finished. Workers run insidepackaged_tasks that are only waited on, so the exception is captured and re-thrown rather than discarded. - Any other
HttpErroror exception is re-thrown from the worker.
Buffering
flush_buffer(deadline_millis = -1) flushes this builder’s queue/partition buffer; throws with
no queue name. get_buffer_stats() on the client returns the aggregate. Buffers are keyed on
"<queue>/<partition>", so builders addressing the same pair share one, and a buffer flushes at
message_count messages or time_millis after its first message.
The buffer is bounded and lossless under errors. At max_size waiting messages (default
4 * message_count), further adds block on the flusher instead of growing the heap. A batch whose
POST fails goes back to the front of the buffer, in order, and is retried every
retry_delay_millis until it lands or the client closes; it is never dropped. A broker outage
therefore shows up as blocked producers and a full buffer, not as silent loss.
A flush with no deadline retries a failing batch until it lands. Pass deadline_millis >= 0 to
flush_buffer or flush_all_buffers to bound that: when it expires, a BufferFlushError reports
how many messages are still buffered (still buffered, not dropped) via unflushed_count() and in
what(). close() flushes under a 30 s deadline, prints what could not be sent, and only then
discards; a buffered push after close() throws instead of being accepted by a client that will
lose it.
Dead-letter queue
dlq(const std::string& consumer_group = "") returns a DLQBuilder; throws with no queue name.
| Method | Notes |
|---|---|
limit(int) / offset(int) |
Paging. |
from(const std::string&) / to(const std::string&) |
Time filters. |
get() |
json, shaped {"messages": [...], "total": n} |
Read-only, and this client has no admin surface, so replay means reading the row and pushing the
payload again, or calling one of the broker’s own replay routes yourself:
POST /api/v1/messages/{partitionId}/{transactionId}/retry by address, or
POST /api/v1/dlq/{id}/replay by dead-letter row id, which is the one that can name a different
destination. Both move the record rather than copying it, so re-pushing the payload by hand is the
only one of the three that leaves the dead-letter row behind.
TransactionBuilder
client.transaction(). Pushes and acks in one PostgreSQL transaction, all-or-nothing.
| Method | Notes |
|---|---|
ack(const json& message, const std::string& status = "completed", const json& context = json::object()) |
Returns TransactionBuilder&. Requires transactionId and partitionId; a leaseId is collected into requiredLeases. Unlike QueenClient::ack, the status here is a string, so retry and dlq are reachable. |
queue(const std::string& queue_name) |
Returns a QueuePushBuilder with partition(...) and push(...). |
commit() |
json. Posts POST /api/v1/transaction. |
A transaction is atomic, not exactly-once end to end; see the limit.
HttpClient
There is no Admin class in the C++ client. Management and observability routes are reached
through the shared HttpClient from get_http_client():
auto http = client.get_http_client();
queen::json overview = http->get("/api/v1/resources/overview");| Method | Signature |
|---|---|
get |
get(path, request_timeout_millis = 0, retry_kind = RetryKind::Default) |
post |
post(path, body = nullptr, request_timeout_millis = 0, retry_kind = RetryKind::Default) |
put |
put(path, body = nullptr, request_timeout_millis = 0, retry_kind = RetryKind::Default) |
del |
del(path, request_timeout_millis = 0, retry_kind = RetryKind::Default) |
get_load_balancer |
get_load_balancer() const |
All four return json, with nullptr for an empty body or a 204
(a 204 carries no body at all), and throw HttpError on any status
>= 400. request_timeout_millis of 0 uses the
client default. Pass RetryKind::Pop for a long-poll request so a 429 backs off indefinitely
instead of using the bounded budget.
The complete route list is in the route table.
HttpError
Derives from std::runtime_error, with what() set to the broker’s error string, so existing
catch (const std::exception&) sites keep working.
| Accessor | Notes |
|---|---|
int status_code() const |
HTTP status. |
const std::string& body() const |
Raw body. |
const std::string& code() const |
The body’s code field; empty when absent. The proxy’s stable values are listed under Behind the proxy. |
std::optional<double> retry_after_seconds() const |
From the Retry-After header on a 429. |
bool is_cluster_suspended() const |
The terminal 403 no retry resolves. |
A transport failure with no response at all surfaces as a plain std::runtime_error, not an
HttpError.
LoadBalancer
Constructed automatically when the two-argument QueenClient gets more than one URL; throws
std::invalid_argument on an empty vector. get_next_url(session_key = "") returns a sticky URL
for "session" and a round-robin URL otherwise. get_all_urls(), get_strategy() and reset()
are also public.
util helpers
| Function | Notes |
|---|---|
generate_uuid_v7() |
The id used for transactionId; monotone within a process. |
is_valid_uuid(const std::string&) |
|
url_encode(const std::string&) |
|
parse_url(const std::string&) |
(scheme, host, port); port defaults to 443 for https, 80 for http. |
get_iso_timestamp() |
UTC, millisecond precision. |
compute_retry429_delay_millis(...) |
The backoff the HTTP layer applies; exposed for tests. |
is_log_enabled(), log(), log_warn(), log_error() |
Gated by QUEEN_CLIENT_LOG, read once. |
Not in this client
There is no streaming SDK for C++ and no Admin facade. The Stream builder, windows, gates and
the /streams/v1/* runtime exist in the JavaScript, Python, Go and Rust clients.
Same surface, other languages
Parity here is a claim about the surface, not about shared code: each client is written natively
in its own idiom. These sixteen rows are that surface, read out of clients/ on this tree. Six
of them are full parity across all six clients; the other ten are where a client stops.
| Capability | JavaScript | Python | Go | Rust | PHP | C++ |
|---|---|---|---|---|---|---|
| Push, pop, ack, multi-partition claim | yes | yes | yes | yes | yes | yes |
| Client-side push buffering | yes | yes | yes | yes | yes | yes |
Transactions through POST /api/v1/transaction |
yes | yes | yes | yes | yes | yes |
| Dead-letter reader | yes | yes | yes | yes | yes | yes |
| HTTP 429 policy, separate from the retry counter | yes | yes | yes | yes | yes | yes |
kv and timers riders on a transaction |
yes | yes | yes | yes | yes | yes |
| Key/value: the seven operations | yes | yes | yes | yes | yes | five of seven |
Timers: schedule, cancel, peek, list |
yes | yes | yes | yes | yes | two of four |
once, the idempotency marker in one call |
yes | yes | no | no | no | no |
Admin facade |
yes | yes | yes | yes | yes | no |
affinity load-balancing strategy |
yes | yes | yes | yes | yes | no |
Streams DSL over /streams/v1/* |
yes | yes | yes | yes | no | no |
| Lease renewal inside the consume loop | yes | yes | yes | yes | yes | no |
Per-message trace helper |
yes | yes | yes | no | yes | no |
Host override independent of the address dialled |
yes | no | no | yes | no | no |
pop reports a failure instead of an empty result |
no | no | yes | yes | yes | no |
The ten divergent rows, with their conditions:
- Key/value. The seven operations are
get,getMany,getPrefix,put,putIfAbsent,deleteandincr. C++ wraps five of them:getManyandgetPrefixhave no method, and both remain reachable throughPOST /api/v1/kvon the sharedHttpClient.getPrefixinside a transaction is refused by the broker in every client, which is its rule and not a client’s gap. - Timers. C++ has
scheduleandcancelonly;peekandlistare reads and stay onGET /api/v1/timers/{queue}[/{timerKey}]. There is no reschedule operation anywhere, becausescheduleis the upsert andstatusreports which happened; Python, Go, Rust and PHP still spell areschedulealias over it, JavaScript and C++ do not. once. JavaScript (kv.once,transaction().once) and Python (kv.once,transaction().once) foldputIfAbsentplusrequiredinto the question people actually ask, “did I win?”. The other four write theputIfAbsentthemselves, which is the same wire and one line longer.kvon a streams operator context. No client has it, so it is not a row. Inside a stream the state primitive isstate_ops, which commits with the sink and the ack in the cycle’s own transaction; a key/value write from an operator would not, and that atomicity is the thing the stream already gives you for free.Adminfacade. C++ has noAdminclass. Every management and observability route is still reachable, through the sharedHttpClientreturned byget_http_client().affinity. The C++LoadBalancerimplements round-robin and"session"only, andload_balancing_strategy = "affinity"falls through to round-robin silently. The strategy matters because it keeps one consumer’s pops on one backend, which is what keeps two clients from contending on the same partition claim.- Streams. The
Streambuilder, the four window kinds, gates and the/streams/v1/*runtime are in the JavaScript, Python, Go and Rust clients. PHP and C++ have none of it. - Lease renewal. In C++,
renew_lease()sets a flag the consumer loop does not act on: the worker carries an unimplemented placeholder where the timer would go. Callclient.renew(message)yourself before the lease expires. - Per-message
trace. Where it exists it is attached by the consume loop, so a message returned by a manualpop()never carries it. Rust records a trace throughAdmin::record_traceinstead of hanging a method off the message. C++ cannot hang one off a JSON object at all, and its trace hook is an explicit no-op. Hostoverride. JavaScript (hostHeader) and Rust (host_header) advertise a request authority, and a TLS SNI, independent of the address the socket dials. That is what addresses a named tenant cluster behind a shared proxy endpoint. The other four clients send the dialled address.popfailures. Go, Rust and PHP surface a 4xx, an exhausted 429 budget, a terminal 403 or a network fault to the caller, so an empty result means an empty queue. JavaScript, Python and C++ log the failure and return an empty result, which does not distinguish an empty queue from revoked credentials.
The cpp suite in the test matrix runs test_retry429, which
needs no broker, then test_client against a live one, on the single, ha and tenanted
topologies. That suite plus its 14 proxy-contract assertions is green on this tree.
JavaScript
Thenable builders plus the streaming SDK.
Python
Async client with the same builder chain.
Go
Context-first, explicit Execute(), full Admin facade.
Rust
Async on tokio, broker wire types, full streams runtime.
PHP
Synchronous Guzzle client with Laravel integration.
queenctl
The same operations from a shell.