Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

Refusal wire reference

Every field, header, and status a governance stop can carry, on the response and on the matching ledger row.

ReferenceLast updated

On this page

A 429 has landed in someone’s error handler, or a row has landed in your ledger, and you need to know what each field means, whether it will be there next time, and what its absence tells you.

This page is the field list. It covers the eight stop types governance can produce, the JSON body, the response headers, the success-side headers a caller can plan against, and the block the decision row carries for the same request.

For the story behind one 429, which limit fired and at which node and what to change, read Why was this request refused?. For why a spend cap discloses an approximation at all, read how a limit counts.

How a stop is produced

Governance has one exit. When a limit, a scope check, or a credential lookup decides a request does not go through, the engine hands the proxy a single typed value, and one function turns that value into everything anyone will ever see about it.

From that one value the function stamps the response headers and builds a body value. The body bytes and the ledger row’s refused_by_limit block are both written from the body value, so those two cannot drift. All of it happens on the shared terminal path every other outcome uses.

Two consequences are worth relying on. One stopped request produces one ledger row, so a stop is not a silent drop. And the row carries the same fields the caller received, minus error.message, so a pasted response and your ledger cannot disagree about what happened. Governance covers the same ground in prose.

Reason to status

error.type names the reason, and the status is derived from it, so the two cannot disagree. The same string rides the x-tt-refusal-type header.

error.type Status What produced it
invalid_key 401 a presented ttv_ credential did not resolve — unknown, revoked, or lapsed — or, under require_key: required, no credential was presented at all
scope_violation 403 the endpoint, model, or provider sits outside the key’s scope
rate_limited 429 an rpm or tpm limit denied the step at its gate
quota_exhausted 429 a quota_tokens or quota_requests cap is used up for the period
budget_exceeded 429 a budget_usd cap is used up for the period
concurrency_exceeded 429 a concurrency lease could not be taken
quota_cost_unfittable 429 one request costs more than the window’s burst can ever hold
enforcement_unavailable 503 the enforcement question could not be answered, under fail: closed with enforce: hard, or under require_key: required with no loadable key snapshot

The vocabulary is closed. A ninth value means a version newer than this page.

The response body

Every governance stop returns application/json with one top-level error object. Which of its four members appear is decided by the type. The smallest body is the 401:

$ curl -i -X POST http://127.0.0.1:8788/v1/chat/completions \
    -H 'content-type: application/json' -d '{"model":"gpt-4o-mini","messages":[]}'
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Tt-Refusal-Type: invalid_key
X-Tt-Request-Id: tt_7niasub57sgh4gotj223c2y4f4

{"error":{"type":"invalid_key","message":"invalid or unknown key"}}

That request sent no Authorization header, against a sandbox running require_key: required. The sandbox points at a stand-in upstream, so the port and the request id are ours; the field shape is what your own callers see.

error.type is the string from the preceding table, always present. error.message is human text, also always present: it names the firing limit id on an exhaustion stop, names the violated dimension on a 403, and is uniform on a 401. It is the one member the ledger row does not carry.

error.refused_by

Field Type Carried on Left out when
limit_id string any stop one limit produced 401, 403, and the identity-store 503
limit_type string the same stops as limit_id the same
unit string the same stops; usd, tokens, requests, or concurrent-requests the same
node string every body carrying refused_by at all it is the one member with no omit rule
period string a limit with a period: the calendar name (day, week, month) or the window duration (1m0s) concurrency, which has none
resets_at RFC 3339 string a calendar-period limit rate windows and concurrency
retry_after_s integer seconds a 429 whose delay is above zero 401, 403, both 503s, and any zero delay
observed number budget_usd, quota_tokens, quota_requests, concurrency rpm, tpm, and the 503s
limit number the same stops as observed the same

Two omission rules matter more than the rest. retry_after_s is left out rather than sent as 0 when no retry time is known, so a client that reads it can act on it. And a 401 carries no refused_by object at all: naming a node or a limit there would confirm which tenant a presented key belongs to. A 403 does carry the object, holding node alone.

budget_usd renders observed and limit in dollars, and every other type renders a whole count. The dollar counter is kept in nanodollars and divided on the way out.

error.enforcement

Field Type Carried on Left out when
posture string enforcing on a limit stop, fail_closed on either 503 the whole object is absent on 401 and 403
approximate bool every body carrying enforcement present even when false
bound object a spend or quota stop with an accountant wired, and a rate stop taken during a coordination-store outage every other stop
note string travels with bound when there is no bound

This object is where the four counting classes become visible on the wire. Here it is from a captured budget_usd 429:

{"posture":"enforcing","approximate":true,"bound":{"tee_lag_rows":0,"dropped_rows":0},"note":"spend from in-flight and streaming requests admitted before this refusal may not yet be reflected"}

Three other limits, driven against the same sandbox, carry three different shapes:

Firing type Class error.enforcement
rpm AdmissionCounted {"posture":"enforcing","approximate":false}
tpm ReserveReconcile {"posture":"enforcing","approximate":true}
concurrency InflightLease {"posture":"enforcing","approximate":false}

Two of the four claim precision, two admit an approximation, and the two admissions differ in shape: budget_usd attaches a bound object and a sentence, while tpm flags the estimate and stops. How a limit counts explains which class produces which.

The bound object

bound carries only quantitative terms. approximate and note sit one level up, on enforcement, and are not repeated inside it.

Field Type What fills it
tee_lag_rows int the spend and quota accountant: rows queued for folding, not yet folded
dropped_rows int the same queue, counting rows shed under load
in_flight_reserved int nothing in this build
estimate_skew_p95 float nothing in this build
convergence_lag_rows int the cross-replica converger, on a fleet with shared state

tee_lag_rows and dropped_rows are emitted whenever bound exists, zeros included, because a zero there is the statement that nothing is behind and nothing was shed. The other three are omitted when unset, and two of them have no producer at all: the envelope defines in_flight_reserved and estimate_skew_p95, no code path fills either, and you will not meet them on a served response. Treat them as reserved.

Which limit types reach which terms: budget_usd, quota_tokens and quota_requests get the two queue terms, plus convergence_lag_rows once a shared-state tier is configured. rpm and tpm get a bound only while the coordination store is unreachable, and concurrency and the 401 get none.

Headers

On a stopped request

Header Value grammar Emitted on
Retry-After whole seconds, rounded up a 429 whose delay is above zero; not on 401, 403, or either 503
RateLimit "<limit_id>";r=0;t=<seconds> a 429 where a limit id is known; t is 0 when no delay is known
RateLimit-Policy "<limit_id>";q=<cap> a 429 whose firing limit reports a whole-number cap, which in this build means concurrency
x-tt-refused-by the firing limit_id every stop where a limit id exists
x-tt-refusal-type the error.type string every governance stop
x-tt-coordination the single value suspended a stop whose admission carried a suspension; absent otherwise, with no ok value
x-tt-request-id the per-request id, matching the row’s req_id every response

RateLimit-Policy is dropped for budget_usd and the two quotas, because a q in raw nanodollars or tokens would be read as an IETF request quota. rpm and tpm omit it for a different reason: their counter store cannot cheaply report the pair, which is also why those two carry no observed or limit.

Header names are case-insensitive, and the server canonicalises them on the way out, so a curl -i capture shows Ratelimit and X-Tt-Refusal-Type. Match them case-insensitively. Response headers has the non-governance set.

On a served request

governance.keys.disclose_limits defaults to true. With it on, a 2xx served to a resolved ttv_ key advertises the tightest applying rate-class limit, measured by remaining fraction:

HTTP/1.1 200 OK
Content-Type: application/json
Ratelimit-Policy: "team_rpm";q=300;w=60
X-Ratelimit-Limit-Requests: 300
X-Ratelimit-Remaining-Requests: 59
X-Tt-Request-Id: tt_tbuhetygwddxiaprkw3j3fkwze

A tpm limit advertises the token pair instead:

Ratelimit-Policy: "team_tpm";q=20000;w=60
X-Ratelimit-Limit-Tokens: 20000
X-Ratelimit-Remaining-Tokens: 3609
Header Value grammar
RateLimit-Policy "<limit_id>";q=<cap>;w=<window seconds>; the ;w= term is dropped for concurrency, which has no window
X-RateLimit-Limit-<unit> the cap, with <unit> one of requests, tokens, concurrent-requests
X-RateLimit-Remaining-<unit> the cap minus current use, floored at zero

Three things about those captures. The first limit was configured amount: 300 with burst: 60, and Remaining tracks the burst depth rather than the per-minute rate. That same limit was in mode: shadow and advertised anyway. In the second, 20000 − 16391 = 3609 is one request holding a 16,391-token reserve.

Nothing is advertised when no key resolved, when the key has no rate-class limit, or when the only rate limit is bound to store: redis, whose counter cannot answer a read. Setting disclose_limits: false suppresses all three headers and changes nothing on a stopped request.

The ledger row block

decision.refused_by_limit mirrors the body field for field: type, refused_by, and enforcement, with the same members and the same omission rules. Beside it, decision.governance carries the admission disclosure. Five of its ten members answer most questions a stopped row raises:

Field Meaning
verdict allow or would_refuse, and no other value
suspended limit ids whose guarantee was not in force for this request; __identity names a degraded key store
reserved_tokens the tpm reserve StageB took, when one was taken
serving_key which key served the row: kid, node, and rotation predecessor
gate_us per-stage gate wall time in microseconds

outcome.finish_reason is limit_refused on every governance stop.

State this one plainly, because it costs people a query: decision.governance.verdict is not where a stop is recorded. It holds allow or would_refuse, so a stopped row reads verdict: "allow" unless a shadow limit happened to fire on the same request. Count refused_by_limit, or filter outcome.finish_reason on limit_refused. Here is the row for the 401 shown earlier:

{
  "http": { "path": "/v1/chat/completions", "dialect": "openai", "status": 401, "endpoint": "chat" },
  "decision": {
    "governance": { "kid": "", "node": "", "verdict": "allow", "gate_us": { "a": 0, "b": 0 } },
    "refused_by_limit": { "type": "invalid_key" }
  }
}

The block’s other five members (kid, node, would_refuse, user_asserted, rotation_grace), the rest of the row, and every field outside the governance blocks are decision ledger fields.

What a stopped caller is and is not told

The typed value the engine produces has no exported fields and no exported constructor, so nothing outside the engine can compose one or edit one in transit. Every value that reaches a caller was built by the gate path that fired, carrying that limit’s identity.

What that identity includes is narrowed at two of the statuses, and the narrowing is a choice rather than an oversight.

A 403 names the violated dimension and stops:

HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Tt-Refusal-Type: scope_violation
X-Tt-Request-Id: tt_l3dgk5yfk4evftdngjxjqwkhom

{"error":{"type":"scope_violation","message":"request refused: model outside key scope","refused_by":{"node":"org:acme/team:search"}}}

That key was scoped to gpt-4o-mini,gpt-4o and asked for o3-mini. The message names model as the dimension. It does not name the two models that would have worked, refused_by carries the holder’s own node and nothing else, and there is no enforcement object.

A 401 names nothing. One message covers an unknown key, a revoked key, a lapsed key and, under require_key: required, no key at all. There is no refused_by and no enforcement, and the row is equally bare. The row’s one extra fact is suspended: ["__identity"], which appears when the key store itself was degraded, so an operator can tell a possibly-good caller was turned away against a stale view.

What this page does not cover

Fields, not diagnosis. Which limit fired and what to change about it is Why was this request refused?. Which instance a request selected in the first place is the tenancy hierarchy, and what each type caps is the six limit types. Why a limit was suspended instead of stopping the request is governance. Management API errors are a separate contract, RFC 9457 problem+json, documented at errors and problem+json.