A plan is eleven numbers and a feature flag. This page is what each one does at request time, in the order the gateway evaluates them, and which of them ignore the global enforcement switch.
Every limit is per cluster. Because a cluster lives on exactly one cell and one proxy fronts that cell, the counters are process-local and exact: no shared store, no approximation. The trade is that they reset when the proxy restarts.
The enforcement switch
QUEEN_PROXY_ENFORCE=false # the defaultIn shadow mode every rate and quota decision is still computed, still logged, and
still metered. The request just proceeds anyway. The log line is target: limits with
a would_block=true field, against blocked=true when enforcing. That is what makes
shadow mode useful: you can size plans against real traffic before anything starts
returning errors.
Two families of limit are hard regardless:
| Always enforced | Why |
|---|---|
| Size caps (per-item payload, batch items, request body) | The proxy has to bound what it buffers. There is no honest way to shadow-mode a memory limit |
| The two push-block quotas (storage, monthly messages) | A quota that only warns protects neither the cell’s disk nor the bill |
Order of evaluation
-
Cluster status: suspended or deleting →
403. Produce on a push-blocked cluster →403with the code naming the cause. -
Route class and feature gate.
-
Authentication and authorization.
-
The request bucket: one token, every proxied request.
-
Produce only: buffer the body, apply the size caps, admit each
(queue, partition), then take message tokens. -
POST /api/v1/configureonly: the retention ceiling, then admission. -
Long-poll pop only: take a parked slot, held across the upstream call.
The request bucket
| Plan column | Role |
|---|---|
max_req_per_sec |
Refill rate. NULL means unlimited |
req_burst |
Bucket capacity. NULL falls back to twice the rate |
A classic token bucket: capacity is the burst, refill is the sustained rate, and a fresh bucket starts full so the first burst is immediately available. One token per request, charged after authentication so an unauthenticated flood cannot consume a tenant’s allowance.
Details that change behaviour under load:
- A denial never debits. The bucket state is unchanged by a refused request, so shadow-mode denials do not skew later decisions.
- A plan upgrade does not hand out free tokens. Raising capacity leaves the current token count alone; lowering it clamps the count down.
NULLis free. An unlimited limit returnsAllowwithout touching the shard map at all, so an unlimited cluster costs nothing.Retry-Afterisceil(deficit / refill_rate), clamped to 1–60 seconds.
Idle cluster entries are pruned lazily: after 10 minutes without traffic, at most one sweep every 2 minutes, piggybacked on real requests so the sweep never adds latency to the request path. A parked entry with outstanding slots is never pruned.
Size caps
All three are hard.
| Cap | From | Applied to | Response |
|---|---|---|---|
| Per-item payload | max_payload_bytes |
Each item in a push or transaction, measured as the byte length of the payload’s raw JSON text | 413 payload_too_large, naming the item index |
| Batch items | max_batch_items, else QUEEN_PROXY_MAX_BATCH_ITEMS (10000) |
Number of counted push items in the request | 413 payload_too_large |
| Request body | QUEEN_PROXY_MAX_BODY_BYTES (16 MiB) |
The whole buffered body of a produce or /configure request |
413 payload_too_large |
The per-item cap is deliberately not a body-total cap: a batch of many small items must not be refused by the single-item ceiling. The body ceiling is the instance’s own protection and is not a plan value.
Measuring the payload as its raw JSON text means what the client sent, verbatim.
{"a":1} is 7 bytes, "hello" is 7 bytes including the quotes. Whitespace the client
included counts.
The message bucket
| Plan column | Role |
|---|---|
max_msgs_per_sec |
Refill rate |
msgs_burst |
Capacity, defaulting to twice the rate |
The charge is n tokens, where n is the number of push items the proxy counted in
the body: items[] for /api/v1/push, and for /api/v1/transaction the flattened
push operations, both the flat {queue, payload} form and the nested {items:[…]}
form. Ack operations are skipped; their ownership is checked by the broker.
Two behaviours worth knowing:
- An unparseable produce body is forwarded without message enforcement. The proxy
does not half-enforce a batch it could not read; the broker returns its own
400. A warning is logged. - Deliveries are debited after the fact. When a pop returns messages, that count is taken from the same message bucket unconditionally, and it can drive the bucket negative. A long-poll pop is exempt at entry (it would otherwise be charged for waiting) and charged at completion. The consequence is real: one large pop can put a cluster into token debt, and the next push is denied until the bucket refills back above zero.
n = 0 is always allowed and never touches the bucket.
Queue and partition caps
| Plan column | Enforced on |
|---|---|
max_queues |
Each distinct queue named in a produce request or created by /configure |
max_partitions_per_queue |
Each distinct (queue, partition) pair |
Both matter because a push creates queues and partitions implicitly. And a push
is not the only implicit creator: on the broker, a wildcard pop or a subscription
registration against a queue that does not exist provisions its queen.queues
config row too, because subscribing before the first push has to work, so a consumer
typo creates a queue row where it previously left only an orphan cursor. Such a
row carries no partitions until something is pushed to it. The proxy
keeps the exact pairs it has admitted for the cluster in memory: a known pair is a set
lookup with no database round trip, and a new one is evaluated under one lock and then
recorded.
The partition count used for the check is max(pairs seen in this process, the last count read from the database) + 1. The database side stores only a count, never
partition names, so after a restart the count is a floor, which is exactly what
stops a restart being used to slip past max_partitions_per_queue before the reconciler
catches up.
Over cap is 403 {"code":"quota_exceeded"} when enforcing, a would_block log line
when not.
/configure is admitted too, because it is the explicit creation path: the
underlying stored procedure creates the queue plus exactly one partition named
Default, so one admission call covers what the request can create.
The retention ceiling
max_retention_seconds caps what a tenant may ask to keep. On POST /api/v1/configure
the proxy reads retentionSeconds and completedRetentionSeconds (as a number or as
a numeric string, because the stored procedure reads them as text and casts) and
refuses the first one above the ceiling with 403 quota_exceeded, naming the option and
both values.
It refuses rather than clamps. The proxy is parse-only and never rewrites a request body; silently shortening a customer’s retention would make their data disappear later with no signal at the moment they asked for it.
A non-positive or absent value means that retention rule is disabled (data kept forever) and is allowed, because it is what every client sends by default and refusing it would break an out-of-the-box queue creation. Unbounded growth is the storage quota’s job, not this ceiling’s.
Parked consumer slots
A long-poll pop (wait=true) holds a slot for as long as it is parked. Two caps apply:
| Cap | Scope | Default |
|---|---|---|
max_parked_pops |
One cluster | From the plan |
QUEEN_PROXY_CELL_MAX_PARKED |
The whole cell, across every cluster | 5000 |
The slot is an RAII guard held across the upstream await, so it is released when the
broker answers, when the client disconnects, and when the request is cancelled. There
is no leak path that needs a timeout to clean up. Over either cap is 429 with
Retry-After: 5.
In shadow mode the guard is still granted over cap, on purpose: the request proceeds either way, so the gauge must track it either way or the numbers you are watching are wrong.
The storage quota
max_retained_bytes is the only limit driven by data the broker reports rather than by
the request itself.
-
Every
QUEEN_PROXY_RECONCILE_MS(60 s), the reconciler asks each cellGET /api/v1/resources/queueswith that cluster’s tenant header and sums theretainedBytesfield across its queues. -
A cluster over the cap enters the over-storage set. It leaves only once retained bytes fall back to 90% or less of the cap. That band is hysteresis: without it a tenant parked on the boundary sees
403and201alternating on identical pushes. -
A pump diffs that set every 10 seconds and sets or clears the cluster’s push-block flag, logging the transition.
-
Produce requests for a blocked cluster answer
403 {"code":"storage_quota_exceeded"}. Consumes stay allowed: the way out of a storage block is to drain, so blocking consumption would be a trap.
If the broker’s response carries no retainedBytes field, the quota is simply not
evaluated that cycle: an existing decision is left alone rather than released on a
phantom zero. A cluster already over the cap on the very first pass blocks immediately;
the release band only ever delays the release side.
The monthly message quota
plans.monthly_msgs_quota is a calendar-month allowance, evaluated on the roll-up tick
(QUEEN_PROXY_ROLLUP_MS, default hourly, with the first tick immediately at startup so
a restart re-blocks anything that was blocked).
| Level | Condition | Action |
|---|---|---|
| Warn | msgs ≥ quota × QUEEN_PROXY_QUOTA_WARN_PERCENT / 100 (default 80%) |
One cluster_monthly_quota_warning event into the outbox |
| Over | msgs ≥ quota |
One cluster_monthly_quota_blocked event, and pushes blocked |
Over is ≥, not >: an allowance is exhausted when it is fully consumed, unlike the
storage cap which is a level and blocks strictly above. Each level is announced once per
cluster per month, rising only. A repeat, or a wobble back down, announces nothing. The
count comes from usage_days plus the not-yet-rolled usage_minutes remainder, so it
includes traffic from the current hour.
Blocked pushes answer 403 {"code":"quota_exceeded"} with a message naming
monthly_msgs_quota and saying the block lifts at the next calendar month.
When two blocks are held at once, storage wins the reported code: a tenant whose disk pressure is blocking pushes needs to hear that before a billing ceiling that clears on its own. Releasing one never releases the other.
The 429 contract
Rate limits, and only rate limits, answer 429:
HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/json
{"error":"request rate limit exceeded","code":"rate_limited"}Quota exhaustion is 403, not 429, because retrying does not help. The codes are the
contract:
| Status | Code | Retryable |
|---|---|---|
| 429 | rate_limited |
Yes, after Retry-After |
| 413 | payload_too_large |
No, the request is too big |
| 403 | quota_exceeded |
Not until the quota or the plan changes |
| 403 | storage_quota_exceeded |
Not until data is deleted or ages out. Terminal for a client |
What the clients do
The JavaScript, Python, Go, PHP and C++ clients all carry the same policy, each with its own unit test for it:
429is the only status the retry layer treats as retryable at this level.Retry-Afterin seconds is honoured when present, with ±20% jitter to avoid a synchronised herd.- With no
Retry-After, exponential backoff from 500 ms, capped at 30 s, jittered the same way. - Attempts default to 10 for pushes and administrative calls, and to unbounded
for a long-poll pop, since a consumer loop is meant to keep waiting through transient rate
limiting. Configuring
maxAttemptsapplies it to both.
So a producer against a rate-limited cluster slows down rather than failing, and a
consumer keeps its place in the queue. What a client cannot ride out is a 403: those
surface to the caller with the machine code attached so it can branch without matching
on message text.
One exception to know about: the PHP client’s asynchronous API does not retry 429
in flight. It raises with the error code and the Retry-After value and exposes the
policy so the caller can pace its own loop. The synchronous API retries as above.
Rolling enforcement out
-
Deploy with
QUEEN_PROXY_ENFORCE=falseand watchtarget: limitslines carryingwould_block. Those are exactly the requests that would have been refused. -
Fix the plans, not the traffic, where a real workload trips a cap you did not intend.
set_limit_overrideraises one limit for one cluster without forking a plan. -
Confirm clients handle
429. If they are the shipped SDKs at a current version, they do; if they are hand-rolled HTTP, check forRetry-Afterhandling before you switch on. -
Set
QUEEN_PROXY_ENFORCE=trueand verify withGET /healthz, which reports the live value rather than the boot log, which survives a restart and can therefore describe a process that no longer exists.
Not enforced
For completeness, so nothing here is mistaken for a guarantee:
- No per-queue quota: the caps are per cluster.
- No bandwidth or byte-rate limit. Bytes are metered, not capped.
- No cap on consumer count or connection count, beyond parked long-poll slots.
- Nothing limits how much the proxy database grows apart from
QUEEN_PROXY_USAGE_KEEP_DAYSonusage_minutes.
Related
- Usage metering: where the numbers the monthly quota compares against come from.
- Tenants, clusters and cells: the plan columns and the override merge.
- Endpoints a tenant can reach: which route class each limit applies to.