Skip to content

C++ client

Complete surface of the single-header C++ client: build requirements, ClientConfig with defaults, every builder method, and the behaviours that differ from the other clients.

Updated View as Markdown

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: truecompleted, falsefailed. 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_ack on 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 inside packaged_tasks that are only waited on, so the exception is captured and re-thrown rather than discarded.
  • Any other HttpError or 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, delete and incr. C++ wraps five of them: getMany and getPrefix have no method, and both remain reachable through POST /api/v1/kv on the shared HttpClient. getPrefix inside a transaction is refused by the broker in every client, which is its rule and not a client’s gap.
  • Timers. C++ has schedule and cancel only; peek and list are reads and stay on GET /api/v1/timers/{queue}[/{timerKey}]. There is no reschedule operation anywhere, because schedule is the upsert and status reports which happened; Python, Go, Rust and PHP still spell a reschedule alias over it, JavaScript and C++ do not.
  • once. JavaScript (kv.once, transaction().once) and Python (kv.once, transaction().once) fold putIfAbsent plus required into the question people actually ask, “did I win?”. The other four write the putIfAbsent themselves, which is the same wire and one line longer.
  • kv on a streams operator context. No client has it, so it is not a row. Inside a stream the state primitive is state_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.
  • Admin facade. C++ has no Admin class. Every management and observability route is still reachable, through the shared HttpClient returned by get_http_client().
  • affinity. The C++ LoadBalancer implements round-robin and "session" only, and load_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 Stream builder, 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. Call client.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 manual pop() never carries it. Rust records a trace through Admin::record_trace instead 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.
  • Host override. 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.
  • pop failures. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close