Skip to content

JavaScript client

Complete surface of the queen-mq package: constructor options with defaults, every builder method, the Admin methods that reach a route that exists, and the streaming SDK.

Updated View as Markdown

One npm package, queen-mq, carries both the broker client and the streaming SDK. It is ESM-only ("type": "module"), requires Node 24 or newer, and depends on undici for its own HTTP dispatcher so close() can release keep-alive sockets deterministically. Version 1.3.0, aligned with the broker’s 1.3 line.

import { Queen, Admin, Stream, tokenBucketGate, slidingWindowGate } from 'queen-mq'

The package also exports the default objects themselves (CLIENT_DEFAULTS, QUEUE_DEFAULTS, CONSUME_DEFAULTS, POP_DEFAULTS, BUFFER_DEFAULTS), so an application can read a default rather than restate it.

Constructor

new Queen(config)

config is a URL string, an array of URL strings, or an options object. A string or array is equivalent to passing { url } / { urls }; everything else takes the default below. URLs are validated at construction, and an object with neither url nor urls throws Must provide urls or url in configuration.

Option Type Default Effect
url string none Single broker URL. Ignored when urls is present.
urls string[] none Multiple brokers. More than one entry activates the load balancer.
timeoutMillis number 30000 Per-request abort deadline.
retryAttempts number 3 Attempts for 5xx and network failures. A 4xx is never retried by this counter.
retryDelayMillis number 1000 First retry delay; doubles per attempt.
loadBalancingStrategy 'affinity' | 'round-robin' | 'session' 'affinity' Only consulted when urls.length > 1.
affinityHashRing number 128 Virtual nodes per backend on the consistent-hash ring.
enableFailover boolean true On a 5xx or network error, try the next backend.
healthRetryAfterMillis number 5000 How long a backend stays marked unhealthy.
bearerToken string | null null Sent as Authorization: Bearer <token> on every request.
headers object {} Extra headers on every request.
hostHeader string | null null Bare authority to advertise as Host, independent of the address dialled.
handleSignals boolean true Register SIGINT/SIGTERM handlers that flush buffers and exit.
logger object | null null Custom logger implementing info/warn/error.
structuredLogs boolean false Call the custom logger pino-style, info(fields, message).
retry429 object undefined HTTP 429 backoff policy. See below.

Three consequences worth knowing before you tune anything:

  • loadBalancingStrategy, affinityHashRing, enableFailover and healthRetryAfterMillis are inert with a single URL. The load balancer is only constructed when urls has more than one entry; a single-URL client goes straight to one HttpClient.
  • handleSignals: true installs process.exit(). The handler flushes buffers and then exits the process. Set it to false when the client is embedded in an application that owns its own shutdown, and call close() yourself.
  • headers: { Host } cannot work. fetch() treats host as a forbidden header name and drops it silently. The client detects it, maps it onto hostHeader, and warns on console.warn regardless of the log setting, because a wrong Host at a multi-tenant proxy does not fail loudly: it lands your traffic on the wrong cluster.
  • retryAttempts and failover are mutually exclusive paths. With one URL, or with enableFailover: false, a call makes up to retryAttempts attempts against the same backend (3 total by default). With several URLs and failover on, retryAttempts is not consulted at all: the call walks the backend list, trying each at most once.

retry429

retry429: { maxAttempts, baseMs, capMs } overrides the HTTP 429 backoff policy, which is separate from retryAttempts; the defaults, the Retry-After contract and the jitter are in What the clients do with a 429.

hostHeader

A queen_proxy deployment picks the tenant cluster from the first DNS label of the Host header. When the base URL points at a shared address (an IP, a cell endpoint, a local rig), hostHeader rewrites the request authority (and TLS SNI) while the socket still dials the configured address, the same contract as curl --resolve. Accepts a bare authority only ('acme.eu1.example', 'acme.local:6711'); anything URL-shaped throws at construction.

Queen

Member Signature Returns
queue queue(name = null) QueueBuilder
admin getter Admin, lazily created, one per client
transaction transaction() TransactionBuilder
ack async ack(message, status = true, context = {}) single: one ack result object; batch: { success, processed, results } or { success: false, error, results }
renew async renew(messageOrLeaseId) one { leaseId, success, newExpiresAt } for a single input, an array for an array input
flushAllBuffers async flushAllBuffers() undefined
getBufferStats getBufferStats() { activeBuffers, totalBufferedMessages, oldestBufferAge, flushesPerformed }
deleteConsumerGroup async deleteConsumerGroup(group, deleteMetadata = true) the broker’s response body
updateConsumerGroupTimestamp async updateConsumerGroupTimestamp(group, timestamp) the broker’s response body
enableGracefulShutdown enableGracefulShutdown() this. No-op if handlers are already installed
disableGracefulShutdown disableGracefulShutdown() this
close async close() undefined. Flushes buffers, cleans the buffer manager, removes signal handlers, destroys the HTTP dispatchers

ack(message, status, context)

message is one message object, one transaction-id string, or an array of either. status is truecompleted, falsefailed, or a status string passed through verbatim ('retry', 'dlq'). context accepts { group, error }.

partitionId is mandatory on every message object. Without it the call fails (Message must have partitionId property to ensure message uniqueness): for a single message as a returned { success: false, error }, for a batch as a thrown Error. A leaseId on the message is forwarded when present.

Per-message statuses inside a batch work by tagging items with _status and _error. If any item in the array carries either key, the client switches to per-item mode for the whole batch and the status argument becomes only the fallback.

renew(messageOrLeaseId)

Accepts a lease-id string, a message object, or an array of either. Lease ids are deduplicated first: with multi-partition pop every message in one batch shares a single leaseId, and one extend call renews every claimed partition, so passing the whole message array issues one HTTP call, not N.

QueueBuilder

queen.queue(name) returns a fresh builder. Every configuration method returns this; the terminal methods are push(), pop(), consume(), create(), delete(), dlq() and flushBuffer().

Addressing

Method Default Notes
name (getter) n/a The queue name this builder was created with.
partition(name) 'Default' The ordered lane. A partition other than Default switches pop()/consume() to the partition-scoped route.
namespace(name) null Grouping label, set at create time; also a pop filter.
task(name) null Second grouping label; same dual role.
group(name) null Consumer group. Absent, the broker uses the queue-mode group __QUEUE_MODE__.

Queue lifecycle

Method Wire call Returns
config(options) none this. Merges options over QUEUE_DEFAULTS.
create() POST /api/v1/configure OperationBuilder
delete() DELETE /api/v1/resources/queues/:queue OperationBuilder. Throws immediately if the builder has no queue name.

QUEUE_DEFAULTS, the object config() merges into, carries nine keys: leaseTime, retryLimit, priority, delayedProcessing, windowBuffer, maxSize, retentionSeconds, completedRetentionSeconds and encryptionEnabled, with the values listed under Queue option defaults.

Keys you add to config() are sent verbatim, so any option /configure accepts can be set even though it is not in the defaults object. Two things follow:

  • /configure merges since 1.6.0: an option the body does not carry keeps the value the queue already has, an explicit null restores that option’s default, and top-level "mode": "replace" puts back the pre-1.6.0 behaviour of resetting everything the body omits. This client has no mode setter, so create() always merges. The mechanics and the full option list are in Queue options.
  • Because config() merges over QUEUE_DEFAULTS, those nine keys are on the wire whether you named them or not, so they land on the client’s default rather than on the queue’s stored value. The twelve options outside that object, dedupWindowSeconds and retentionEnabled among them, are the ones a partial call now leaves alone.
  • Calling create() without config() sends QUEUE_DEFAULTS as-is, which means leaseTime: 300, whereas a queue created implicitly by a first push gets a 60-second lease.

Producing

Method Default Notes
buffer(options) off { messageCount, timeMillis, maxSize, retryDelayMillis }, defaults 100 / 1000 / 4 * messageCount / 250. Turns push() into a bounded enqueue.
push(payload) n/a Returns a PushBuilder. payload is one item or an array.

Each item may be { data }, { payload }, or a bare object used as the payload itself. The client mints a UUIDv7 transactionId when the item does not carry one, and sends traceId only when it is a valid UUID. The wire field is always payload; data is a client-side alias.

The broker’s push path stores no trace id, so it is dropped and the message pops back with traceId: null. Push inside a transaction when a trace id has to survive. See Pick your SDK.

clients/client-js/test-v2/docs.jsjs
const res = await client
  .queue('orders')
  .partition('customer-42')
  .push([{ data: { orderId: 9137, amount: 99.5 } }])

Deduplication is keyed on transactionId and enforced in SQL before an offset is allocated, so a duplicate writes nothing:

clients/client-js/test-v2/docs.jsjs
const first = await client
  .queue('payments')
  .partition('customer-42')
  .push([{ transactionId: 'order-9137-paid', data: { orderId: 9137, amount: 99.5 } }])

const retry = await client
  .queue('payments')
  .partition('customer-42')
  .push([{ transactionId: 'order-9137-paid', data: { orderId: 9137, amount: 99.5 } }])
// retry[0].status is 'duplicate': the second push wrote nothing
// and answers with the first message's id.

PushBuilder

Thenable: awaiting it executes the push. then() is idempotent: a second await on the same builder resolves without re-sending.

Method Callback signature
onSuccess(cb) cb(successfulItems)
onError(cb) cb(failedItems, error)
onDuplicate(cb) cb(duplicateItems, error)

Awaiting a non-buffered push resolves to the broker’s per-item array. The four possible item statuses are described on the push route page. With no onError callback registered, at least one failed item makes the await throw; with one registered, it does not throw. Awaiting a buffered push resolves to { buffered: true, count } once the buffer has accepted every item. While the buffer sits at maxSize the await parks until the flusher drains below the bound, so under overload a buffered push is no longer instantaneous: the producer degrades to the flush rate instead of growing the heap. Accepted is not sent; the messages still leave with the flush.

Consuming

Both pop() and consume() read these; the defaults differ between the two.

Method pop() default consume() default Notes
batch(size) 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(n) 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(enabled) 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(enabled) false true Long-poll.
timeoutMillis(ms) 30000 30000 Server-side long-poll deadline. The HTTP layer adds 5 s of slack so the server times out first. Clamped to ≥ 1.
autoAck(enabled) false true See the warning below: the two mean different things.
subscriptionMode(mode) null null all | new.
subscriptionFrom(from) null null 'now' or an ISO timestamp.
conflation(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(count) n/a 1 consume() only. Number of worker loops. Clamped to ≥ 1.
limit(count) n/a null consume() only. Stop after N messages per worker.
idleMillis(ms) n/a null consume() only. Stop after N ms without a message.
renewLease(enabled, intervalMillis) n/a false consume() only. Renews on an interval while the handler runs.
each() n/a batch mode consume() only. Deliver one message per handler call.

pop()

async pop() → array of message objects, [] when there is nothing to return.

clients/client-js/test-v2/docs.jsjs
const messages = await client
  .queue('orders')
  .batch(10)
  .wait(true)
  .pop()

Route selection: a queue name with a non-Default partition uses /api/v1/pop/queue/:queue/partition/:partition; a queue name alone uses /api/v1/pop/queue/:queue; namespace or task without a queue uses /api/v1/pop. No queue, namespace or task throws.

popResult()

async popResult(){ 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 waitMillis pacing hint the consume loop honours in place of its own delay between empty polls. autopilot is null 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(handler, options)

Returns a ConsumeBuilder, thenable like the others. options.signal accepts an AbortSignal that stops every worker at the top of its next iteration.

clients/client-js/test-v2/docs.jsjs
await client
  .queue('orders')
  .group('billing')
  .subscriptionMode('all')
  .limit(1)
  .each()
  .consume(async (message) => {
    console.log(message.data)
  })
ConsumeBuilder method Callback signature
onSuccess(cb) cb(msgOrMsgs, handlerResult)
onError(cb) cb(msgOrMsgs, error)

Registering either callback forces autoAck to false for the run, to prevent a double-ack when the callback acks by hand.

Worker loop behaviour, all of it observable:

  • A handler throw with autoAck on nacks the message and does not rethrow, so the worker keeps going.
  • In each() mode, a nack aborts the rest of the popped batch. The nack releases the lease and clamps the broker cursor at the failed message, so everything after it will be redelivered; continuing would only produce duplicates and rejected acks.
  • A 429 that escapes the HTTP layer’s own retry sleeps for Retry-After (or 1 s) and continues.
  • A network error sleeps 1 s and continues.
  • A 403 stops the worker and rethrows. cluster_suspended cannot resolve itself, and none of the other proxy 403 codes are worth hot-looping.
  • Any other error is rethrown.

message.trace(config)

Messages delivered through consume() are given a trace() method that posts to /api/v1/traces:

await msg.trace({
  traceName: ['tenant-acme', 'room-123'],
  eventType: 'info',
  data: { text: 'started processing' }
})

data is required; traceName accepts a string or an array of strings; eventType defaults to 'info'. It never throws: failures return { success: false, error }.

Buffering

Method Notes
buffer(options) Enable per-queue/partition buffering for this builder’s pushes.
flushBuffer() async. Flush this builder’s queue/partition buffer and wait for in-flight flushes. Throws with no queue name.

A buffer flushes when it reaches messageCount messages or timeMillis elapses since its first message, whichever comes first. flushBuffer() cancels the timer and drains in messageCount-sized batches. Buffers are keyed on "<queue>/<partition>", so two builders on the same queue and partition share one buffer.

The buffer is bounded and lossless under errors. At maxSize waiting messages (default 4 * messageCount), further pushes await 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 retryDelayMillis until it lands or the client closes; it is never dropped. A broker outage therefore shows up as parked producers and a full buffer, not as silent loss.

Dead-letter queue

dlq(consumerGroup = null) returns a DLQBuilder. Throws with no queue name. The builder’s partition is inherited from the queue builder unless it is Default.

Method Default
limit(count) 100, clamped to ≥ 1
offset(count) 0, clamped to ≥ 0
from(timestamp) none
to(timestamp) none
get() async{ messages, total }; { messages: [], total: 0 } on any error

The DLQ is read-only through this builder. Replay lives on the admin façade: admin.retryMessage(partitionId, transactionId).

Since 1.6.0 that route runs on a move: one transaction claims the newest dead-letter row at the address, pushes it back into the log and deletes the row, and the replayed message carries the deterministic transaction id dlq:<dead-letter row id>. Two consequences for a caller. A second call for the same address answers 404, because the row is gone, so a retry after a lost response is safe rather than a second copy. And when the destination’s dedup window already holds that transaction id the verdict is result: "duplicate", which means nothing was written and the dead-letter row was kept (dlqRowRemoved: false). The shape of the 200 body is a superset of what this method has always returned, so nothing that parses replayedAs needs changing. See Messages and dead letters.

TransactionBuilder

queen.transaction(). Pushes and acks bundled into one PostgreSQL transaction, all-or-nothing.

Method Notes
ack(messages, status = 'completed', context = {}) messages is one message or an array. context.consumerGroup sets the group. Requires transactionId and partitionId on every message; a leaseId is collected into requiredLeases. Returns this.
queue(queueName) Returns a sub-builder with .partition(key) and .push(items). push() returns the parent builder, so chains continue.
commit() async. Throws Transaction has no operations to commit on an empty bundle, and throws the broker’s error when the response is not success: true.
clients/client-js/test-v2/docs.jsjs
await client
  .queue('orders')
  .group('invoicing')
  .subscriptionMode('all')
  .each()
  .autoAck(false) // the acknowledgement belongs to the transaction, not to the loop
  .limit(1)
  .idleMillis(5000)
  .consume(async (message) => {
    // commit() throws when the broker rejects the bundle, so reaching the
    // line after it means the ack and the push are both durable.
    await client
      .transaction()
      .queue('invoices')
      .push([{ data: { orderId: message.data.orderId, invoiced: true } }])
      .ack(message, 'completed', { consumerGroup: 'invoicing' })
      .commit()
  })

Lease ids are deduplicated before requiredLeases is sent. Item payloads follow the same data / payload / bare-object rule as push(), and so does the id: a transactionId you set on the item is sent, and one you omit is minted client side. That id is the deduplication key of the pushed message, so setting it deterministically is what makes a retried transaction safe.

A transaction is atomic, not exactly-once end to end; see the limit.

Admin

queen.admin (getter) or new Admin(httpClient). Every method is async and returns the broker’s JSON body verbatim. Methods that take a params object turn it into a query string, dropping null and undefined.

Resources

Method Route
getOverview() GET /api/v1/resources/overview
getNamespaces() GET /api/v1/resources/namespaces
getTasks() GET /api/v1/resources/tasks
listQueues(params = {}) GET /api/v1/resources/queues
getQueue(name) GET /api/v1/resources/queues/:name

Messages and traces

Method Route
listMessages(params = {}) GET /api/v1/messages
getMessage(partitionId, transactionId) GET /api/v1/messages/:partitionId/:transactionId
deleteMessage(partitionId, transactionId) DELETE /api/v1/messages/:partitionId/:transactionId
retryMessage(partitionId, transactionId) POST /api/v1/messages/:partitionId/:transactionId/retry
getTraceNames(params = {}) GET /api/v1/traces/names
getTracesByName(traceName, params = {}) GET /api/v1/traces/by-name/:traceName
getTracesForMessage(partitionId, transactionId) GET /api/v1/traces/:partitionId/:transactionId

Status and analytics

Method Route
getStatus(params = {}) GET /api/v1/status
getQueueStats(params = {}) GET /api/v1/status/queues
getQueueDetail(name, params = {}) GET /api/v1/status/queues/:name
getAnalytics(params = {}) GET /api/v1/status/analytics
getSystemMetrics(params = {}) GET /api/v1/analytics/system-metrics
getWorkerMetrics(params = {}) GET /api/v1/analytics/worker-metrics
getPostgresStats() GET /api/v1/analytics/postgres-stats

Consumer groups

Method Route
listConsumerGroups() GET /api/v1/consumer-groups
getConsumerGroup(name) GET /api/v1/consumer-groups/:name
getLaggingConsumers(minLagSeconds = 60) GET /api/v1/consumer-groups/lagging?minLagSeconds=
deleteConsumerGroupForQueue(group, queue, deleteMetadata = true) DELETE /api/v1/consumer-groups/:group/queues/:queue
seekConsumerGroup(group, queue, options = {}) POST /api/v1/consumer-groups/:group/queues/:queue/seek
refreshConsumerStats() POST /api/v1/stats/refresh

seekConsumerGroup posts options as the body verbatim: { toEnd: true } or { timestamp: '<RFC3339>' }.

System

Method Route
health() GET /health
metrics() GET /metrics
getMaintenanceMode() GET /api/v1/system/maintenance
setMaintenanceMode(enabled) POST /api/v1/system/maintenance
getPopMaintenanceMode() GET /api/v1/system/maintenance/pop
setPopMaintenanceMode(enabled) POST /api/v1/system/maintenance/pop

Streaming SDK

Stream builds an immutable operator chain over a queue and runs it against the broker’s /streams/v1/* endpoints. Every combinator returns a new Stream.

Stream.from(queen.queue('orders'), options?) // → Stream
Group Methods
Stateless map(fn), filter(predicate), flatMap(fn)
Keying keyBy(fn) (at most one per stream, must precede window / reduce / gate)
Windows windowTumbling(opts), windowSliding(opts), windowSession(opts), windowCron(opts) (at most one per stream)
Reduce reduce(fn, initial), aggregate(extractors) (require a preceding window; at most one per stream)
Gate gate(fn) (at most one per stream, incompatible with windowing and reduce)
Sink to(sinkQueueBuilder, opts), foreach(fn) (must be the last operator in the chain)
Terminal run(runOptions)

The size argument differs per window: windowTumbling({ seconds }), windowSliding({ size, slide }), windowSession({ gap }), windowCron({ every }) where every is one of 'second' | 'minute' | 'hour' | 'day' | 'week'. Every window option object also accepts these five, which control when a window closes and what happens to whatever arrives after it did:

Option Default What it controls
gracePeriod 0 Seconds a window stays open past its own end before it closes, so a straggler still lands in the bucket it belongs to.
idleFlushMs 5000, but 1000 for windowSession and 30000 for windowCron How often the runner sweeps partitions that have gone quiet for windows that are ripe to close. 0 disables the sweep, and a window on a silent partition then stays open until traffic returns.
eventTime none msg => epoch_ms. Setting it switches bucketing from the broker’s createdAt to your own timestamp, and turns on a per-partition watermark.
allowedLateness 0 Event-time mode only. Seconds of out-of-orderness tolerated before an event is called late, measured against that partition’s watermark.
onLate 'drop' What happens to an event older than watermark - allowedLateness. 'drop' is the only value that keeps emits exact. 'include' accumulates it anyway, which for an already-flushed window recreates the state row and produces a second emit for it.

gate(fn) receives (value, ctx) with ctx.state (per-key, persisted only on ALLOW), ctx.streamTimeMs and ctx.partitionId. Returning false halts the batch: the runner acks the allowed prefix and does not release the source lease, so the denied message and its successors are redelivered in their original order when the lease expires.

Two ready-made gate functions ship with the package: tokenBucketGate({ capacity, refillPerSec, costFn?, allowZeroCost? }) and slidingWindowGate({ limit, windowSec, costFn? }).

run(runOptions)

async, resolves to a Runner.

Option Default Notes
queryId required Durable identity. Missing it throws run({ queryId }) is required.
url required Broker base URL for the /streams/v1/* calls. Missing it throws.
bearerToken none Auth for the streams calls.
batchSize 200 Messages per cycle.
maxPartitions 4 Partitions leased per cycle.
maxWaitMillis 1000 Long-poll wait on the source pop.
subscriptionMode none 'all' | 'new'.
subscriptionFrom none ISO timestamp or 'now'.
conflation false Requests last-value delivery on the source pop; the reducer only ever sees each partition’s newest message, so messages conflation skips never reach it. See conflation.
consumerGroup streams.<queryId> Override the source group.
reset false Wipe state when the chain’s config hash no longer matches.
onError none (err, ctx) per-cycle error hook.
abortSignal none External cancellation.
logger none Custom logger.

The chain’s shape is fingerprinted into a config hash. Re-deploying a different chain under the same queryId is rejected at registration unless reset: true is passed. Only the operator kinds and their structural config are hashed: user closures cannot be serialised stably, so changing the body of a map does not change the hash.

Runner exposes stop() (graceful drain) and metrics(), which returns queryId, serverQueryId, and the counters cyclesTotal, flushCyclesTotal, messagesTotal, pushItemsTotal, stateOpsTotal, lateEventsTotal, errorsTotal, lastCycleAt, lastFlushAt, lastError.

Logging

With no logger configured, logging is off unless QUEEN_CLIENT_LOG=true is set in the environment (Node) or window.QUEEN_CLIENT_LOG === true (browser). Output goes to console as [timestamp] [LEVEL] [operation] {json}.

A custom logger is always active, env var or not. It must implement info, warn and error; debug falls back to info. By default it is called with one formatted string. With structuredLogs: true it is called pino-style (info(fields, message)), where fields is the detail object plus an operation key. Use that only with a logger that accepts a leading merge object.

Errors

Failures throw an Error with .status (HTTP status) and, when the body carried one, .code. The message is the body’s error string when present. A 429 also carries .retryAfterSeconds. The code values are the proxy’s stable contract, listed under Behind the proxy. A timeout throws with name === 'AbortError' and a .timeout field. Network faults from undici arrive as fetch failed (ECONNREFUSED). The underlying cause is unwrapped into the message rather than left in error.cause.

A 204 response resolves to null: a 204 carries no body at all.

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 js suite in the test matrix runs clients/client-js/test-v2, the broker-free unit tests including the proxy 429 contract and then the live-broker integration run, on the single, ha and tenanted topologies. The unit tests are green at 91 of 91 on this tree.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close