Three things about error handling in Queen are counter-intuitive enough to be worth stating before the tables: a rejected acknowledgement arrives as HTTP 200, a rolled-back transaction arrives as HTTP 200, and an empty pop arrives as HTTP 204 with no body at all. If your client checks status codes only, it will misread all three.
The envelope
Every error body the broker produces is one JSON object with an error string:
{"error":"Authentication required"}The proxy adds a machine-readable code alongside it, and that field is the one to branch on. The human text may change, the codes are a contract:
{"error":"request rate limit exceeded","code":"rate_limited"}Two conventions surround the envelope.
A 204 carries no body whatsoever. Not an empty object, not a zero-length body with a declared length. Nothing. This is deliberate: announcing a content length on a body the HTTP layer then elides made strict HTTP/1.1 clients (Node’s undici) treat the connection as poisoned and drop it, which under empty-poll load turned into connection-reset storms. Every client already early-returns on 204, so the body was pure cost.
An unknown route is a JSON 404, not the dashboard. The broker’s fallback serves the embedded single-page dashboard, but for any path under /api/ and for any non-GET method it answers:
{"error":"Not Found","code":"no_such_route"}Before that guard existed, a call to a nonexistent endpoint came back HTTP 200 with an HTML document, which every JSON client read as success. A phantom endpoint now fails loudly.
Broker status codes
| Code | When | Body |
|---|---|---|
200 |
Reads, acks, transactions, seeks, configure, lease extension | Route-specific JSON |
201 |
POST /api/v1/push accepted (including an empty item list, and including maintenance-mode buffering) |
Per-item result array |
204 |
A pop with nothing to deliver, or a pop while pop-maintenance is on | Nothing |
400 |
Unparseable JSON body; a required field missing; an unsupported operation type in a transaction | {"error":"bad body: …"} or the specific message |
401 |
Authentication required, or the token failed validation | {"error":"Authentication required"}, Token has expired, Invalid token signature, Unknown key ID, … |
403 |
The token authenticated but its roles do not satisfy the route | {"error":"Insufficient permissions"} |
404 |
Unknown route; an addressed message or dead-letter row that does not exist; a stored procedure reporting an error whose text contains “not found” | {"error":"Not Found","code":"no_such_route"} or the route’s own message |
409 |
POST /streams/v1/queries with a config-hash mismatch and no reset |
{"error":"…","success":false} |
413 |
Request body larger than QUEEN_MAX_BODY_BYTES (default 64 MiB) |
Framework response |
500 |
Connection pool exhausted or unavailable; a stored procedure error; a decode failure | {"error":"pool"}, {"error":"push failed: …"}, … |
503 |
GET /health when the database round-trip fails |
Health payload |
401 and 403 only occur when JWT validation is switched on. Access levels are a role set, not a ladder: a write-only credential passes POST /api/v1/push and is rejected with 403 on every read and every consume route, including /api/v1/pop. The per-route level is listed in the generated route table in the reference.
Where 200 does not mean success
-
Acknowledgements.
POST /api/v1/ackand/api/v1/ack/batchalways answer 200 with one item per acknowledgement, in request order:[{"index":0,"transactionId":"…","success":false,"error":"invalid or expired lease","leaseReleased":false,"dlq":false,"noop":false}]successon the item is the only signal. The three rejections you will actually see areinvalid or expired lease,already committed: the cursor moved past this message before this ack, andunresolvable: transaction not in the ack window (hash purged or never pushed); if leased, the message redelivers. A harmless duplicate completion reportssuccess: truewithnoop: true. In every rejection case the cursor did not move, so the messages redeliver. -
Transactions.
POST /api/v1/transactionanswers 200 even when the transaction rolled back:{"transactionId":"…","success":false,"error":"QDUP …","results":[]}A duplicate push (
QDUP) or a rejected ack (QTXN) takes the whole batch down. Only a malformed body or an unsupported operation type produces a 400. -
Stream cycles.
POST /streams/v1/cycleanswers 200 for any completed procedure call.successanderrorin the body decide; a 500 there means an infrastructure error, and because the whole call is one transaction, retrying is safe.
The SDKs handle all three: the JavaScript, Python and Go clients raise on success: false for transactions and cycles, and normalise ack results per item, so a truncated or misaligned ack response fails loudly rather than being misattributed to the wrong message.
Behind the proxy
A proxy deployment adds its own error surface in front of the broker. Every response carries a code; these are the ones a client should switch on.
| Status | code |
Cause | Terminal for the client |
|---|---|---|---|
401 |
unauthorized |
Missing bearer credential, unknown or revoked API key, invalidated session | Yes (fix the credential) |
403 |
cluster_suspended |
The cluster is suspended or being deleted | Yes |
403 |
storage_quota_exceeded |
Storage quota reached; pushes blocked | Yes |
403 |
quota_exceeded |
Monthly message quota exhausted, or a queue/partition/retention ceiling refused | Yes |
403 |
push_blocked |
Billing hold on the cluster | Yes |
403 |
feature_gated |
The route belongs to a plan feature that is off (streams, traces) | Yes |
404 |
route_blocked |
The route is never exposed to a tenant, or an operator route on a cell with the capability off | Yes |
413 |
payload_too_large |
A single item over the plan’s payload cap, a batch over the item cap, or a body over the instance cap | Yes (resize the request) |
421 |
cluster_unknown |
The request’s Host does not resolve to a cluster | Yes (fix the endpoint) |
429 |
rate_limited |
Request-rate bucket, message-rate bucket, or parked-consumer slots exhausted | No (retry after backoff) |
502 |
bad_gateway |
Upstream unreachable, or a bad upstream URI | No (retriable) |
504 |
upstream_timeout |
The broker did not answer inside the proxy’s timeout | No (retriable) |
Note the asymmetry in how quotas are reported. A rate over-limit is a 429 and you should wait. A quota over-limit is a 403 and waiting will not help: the number is not going to refill this month, and a queue-count ceiling will not raise itself. Retrying a 403 is a busy loop.
The 429 and Retry-After contract
Every 429 from the proxy carries a Retry-After header in seconds. Its value comes from the limiter that denied the request:
- Token-bucket denials (request rate, message rate) compute
ceil(deficit / refill_rate), clamped to between 1 and 60 seconds. A denial never debits the bucket, so a shadow-mode deny does not distort later decisions. - Parked-consumer denials (too many concurrent long-poll pops holding slots) use a fixed 5 seconds.
Retry-After is advice about when capacity returns, not a promise. Honour it, and add jitter.
413 in particular
The proxy enforces three separate size limits, and the message tells you which one you hit:
| Limit | Message shape |
|---|---|
| Per item, from the plan | item 3: payload 2097152 bytes exceeds max_payload_bytes (1048576) |
| Batch item count | batch of 20000 items exceeds max_batch_items (10000) |
| Request body, per instance | request body exceeds cap |
The per-item cap is checked per item precisely so a batch of many small messages is not rejected by the single-item ceiling. The right response to a 413 is to reshape the request, not to retry it: send smaller batches, or a payload that carries a reference instead of the blob. Note that the broker’s own body limit (QUEEN_MAX_BODY_BYTES, 64 MiB) is separate and larger than the proxy’s default request-body cap (16 MiB), so behind a proxy the proxy’s cap is the one you meet first.
What the SDKs retry for you
The JavaScript, Python and Go clients share the same two-layer policy, configured independently.
Layer one: 5xx and network failures. retryAttempts (default 3) attempts with exponential backoff starting at retryDelayMillis (default 1000 ms). With several backend URLs configured, a 5xx or network failure also marks that backend unhealthy and fails over to another. Any 4xx short-circuits this loop: client errors are not retried.
Layer two: 429 only. A separate retry429 policy retries in place, against the same backend, because a rate limit is not a backend-health signal and must not trigger failover.
| Setting | Default | Notes |
|---|---|---|
maxAttempts |
10 for ordinary requests; unbounded for a long-poll pop | Setting it explicitly applies the same bound to both kinds |
baseMs |
500 | Exponential base used when the response has no Retry-After |
capMs |
30000 | Ceiling on the computed delay |
The delay is Retry-After × 1000 when the header is present, otherwise min(capMs, baseMs × 2^attempt); either way it is jittered by ±20% to avoid a synchronised thundering herd.
The unbounded default for long-poll pops is deliberate. A consumer’s poll loop is already unbounded, so an individual 429 should be waited out rather than surfaced as a failure after a handful of tries. Ordinary requests (push, admin calls, non-waiting pops) get the bounded budget so a producer does not hang forever.
The streams runtime uses its own smaller HTTP client for /streams/v1/*: up to 3 retries with exponential backoff from 100 ms to 5 s, retrying 5xx, network failures, 408 and 429, and treating every other 4xx as terminal. It does not read Retry-After.
Durability when the database is down
A push that cannot reach PostgreSQL is not simply an error. The broker appends it to an on-disk spool and a background loop replays it later; the same path handles maintenance mode, where every push is diverted and reported with per-item status: "buffered" at HTTP 201. A spool write that itself fails downgrades the response to 500 with the failing items marked failed.
A checklist for a robust client
- Read the per-item
successon ack responses; do not infer it from the status code. - Read
successon transaction and stream-cycle responses for the same reason. - Treat
403and413as terminal. Log thecodeand stop; do not retry. - Treat
429as backpressure. HonourRetry-After, jitter it, and let long-poll pops wait indefinitely. - Treat
502,504and network failures as retriable, with backoff and failover. - Make handlers idempotent on
transactionId. Every rejected ack, every lease expiry and every replay is a redelivery. - Expect
204with no body from a pop, and do not attempt to parse it.