AI Gateway Architecture: Routing, Quotas & Streaming
This page is the mechanics page: the exact request-lifecycle pseudocode, the atomic admission algorithms, the routing scoring function, the adapter capability contracts, and the streaming and control-plane internals that Complete Design introduced at a narrative level. Read Complete Design first if you haven't — this page assumes you already have the end-to-end shape in mind.
Request Lifecycle Pseudocode
Every request — streaming or not — moves through the same seven stages. The ordering is load-bearing: authorization happens before any upstream call, admission (the atomic reservation) happens before routing, and settlement happens exactly once however the request ends: success, failure before the response started, failure after it started, or client cancellation.
function onRequest(req):
# 1. AUTHENTICATE — validate the virtual key, never trust caller-supplied headers
tenant = authenticate(req.virtualKey)
if tenant is null: return 401 Unauthorized
# 2. AUTHORIZE — tenant + logical model + capability policy check
policy = loadPolicy(tenant, cacheOk=true) # control-plane snapshot, not a live call
if not policy.allows(req.logicalModel, req.capabilities):
return 403 Forbidden # rejected before any upstream cost
# 3. ESTIMATE — worst-case token/cost bound for this request
estInputTokens = tokenize(req.prompt, model=req.logicalModel)
estOutputTokens = req.maxTokens ?? policy.defaultMaxTokens
estCostUsd = price(policy, estInputTokens, estOutputTokens)
# 4. RESERVE — one reservation per client request, created once and owned by this
# request until it settles. Atomic across every gateway replica (Invariants I2, I3),
# and idempotent on req.requestId so a client retry cannot reserve twice.
reservation = quotaStore.tryReserveAtomic(
id=req.requestId, tenant=tenant, rpmUnits=1,
tpmUnits=estInputTokens + estOutputTokens, usdUnits=estCostUsd,
expiresIn=policy.reservationTtl)
if not reservation.ok:
return 429 TooManyRequests, Retry-After: reservation.retryAfterSeconds
outcome = Outcome.NO_ATTEMPT
try:
outcome = executeWithFailover(req, tenant, policy, reservation) # steps 5-6
finally:
settle(reservation, outcome) # step 7: runs on success, failure and client cancel
# If this process dies before reaching here, the reservation expires and the
# reconciler settles it (see "Crash recovery and reconciliation").
return outcome.toClientResponse()
function executeWithFailover(req, tenant, policy, reservation):
deadline = now() + policy.maxRequestSeconds # total time budget across ALL attempts
tried = []
while len(tried) < policy.maxAttempts and now() < deadline:
# 5. ROUTE — filter on hard constraints, then score the eligible set
eligible = [d for d in modelRegistry.deploymentsFor(req.logicalModel)
if d not in tried
and d.supportsResidency(tenant.region)
and d.supportsCapabilities(req.capabilities)
and d.circuitState != OPEN]
if eligible.isEmpty(): break
deployment = highestScored(eligible, weights=policy.routingWeights)
tried.append(deployment)
# 6. ADAPT + CONNECT — every attempt runs under the SAME reservation
attempt = reservation.recordAttempt(deployment) # marks that upstream may now be running
adapter = adapters[deployment.provider]
try:
stream = adapter.call(deployment, adapter.translate(req),
timeout=min(policy.attemptTimeout, deadline - now()))
first = stream.awaitFirstChunk()
except ProviderRejected: # 429/5xx/connect error: provider did not run it
attempt.markNotExecuted() # costs nothing, so no extra reservation needed
continue
except UpstreamTimeout: # request was sent, no answer: it MAY have executed
attempt.markUnknownUsage() # reconciled later from the provider's usage report
if not quotaStore.extendReservation(reservation.id, estimateFor(deployment)):
break # cannot cover a possible second execution: stop
continue
# First byte reaches the client: the response is now committed, no more failover.
return relay(req.clientConn, stream, first, attempt)
return Outcome.fromAttempts(tried) # NO_ELIGIBLE (503) or EXHAUSTED (502/504)
function relay(clientConn, stream, first, attempt):
try:
clientConn.write(first)
for chunk in stream: # chunks already normalized by the adapter; bounded buffer
clientConn.write(chunk)
return Outcome.COMPLETE(usage=stream.reportedUsage)
except UpstreamError: # failure AFTER the response started
emitStreamError(clientConn, incomplete=true) # Invariant I4: never splice another generation
return Outcome.FAILED_MIDSTREAM(usage=stream.reportedUsageOrNull)
except ClientDisconnected: # caller cancelled
stream.cancel() # best effort; provider may still bill
return Outcome.CLIENT_CANCELLED(usage=null)
function settle(reservation, outcome):
if not reservation.anyAttemptMayHaveExecuted(): # nothing was sent, or every attempt was refused
quotaStore.releaseIfReserved(reservation.id) # RESERVED -> RELEASED, full refund, no-op if already settled
return
if outcome.usage is null or reservation.hasUnknownUsageAttempt():
quotaStore.markPendingReconciliation(reservation.id) # worst case stays charged until usage is known
return
ledger.insertUsage(reservation.id, outcome.usage, priceVersion=reservation.priceVersion)
# durable, unique on reservation.id, so a replay inserts nothing new
quotaStore.settleIfReserved(reservation.id, outcome.usage)
# RESERVED -> SETTLED and refund (reserved - actual), in one atomic step; no-op if already settled
telemetry.record(reservation.correlationId, reservation.tenant, outcome)Virtual Keys & Credential Management
A virtual key is a credential the gateway's own control plane issues; the provider never sees it, and the provider's real credential never leaves the gateway process boundary.
Storage
Virtual keys are hashed at rest exactly like password credentials (a keyed hash, e.g. HMAC-SHA256 with a server-side pepper) — a leaked database dump should not hand out usable keys. Only the hash and a short, non-secret prefix (for display/debugging, e.g. 'vk_live_8f2c…') are stored; the plaintext key is shown to the tenant exactly once, at creation.
Rotation without downtime
A tenant can hold two active virtual keys simultaneously during a rotation window — the old key keeps working until the tenant confirms the new one is deployed, then the old key is revoked. Revocation takes effect on the next policy snapshot, bounded by the freshness window discussed in Chapter 10.
Provider credential vaulting
Real provider API keys live in a secrets manager the control plane reads from, never in the policy snapshot pushed to gateways in plaintext — gateways receive a reference/alias and fetch the actual secret through a short-lived, audited path, or the control plane injects it into the adapter layer directly at call time. Either way, a compromised gateway replica's memory dump should expose at most the credentials for requests currently in flight, not the entire credential vault.
Distributed Quota Algorithms
The admission check has to be correct across every gateway replica and fast enough to sit on every request's critical path. Three mechanisms cover the realistic design space.
Token bucket, atomically decremented
A Redis Lua script (or equivalent single-round-trip atomic operation) checks the current bucket level and decrements it in one indivisible step: if bucket[key] >= cost then bucket[key] -= cost; return OK else return DENY. Because Redis executes Lua scripts atomically, two replicas racing for the last unit cannot both succeed — exactly the fix for the naive-local-counter race.
Sliding window vs. fixed window
A fixed window (reset every 60s on the clock) is simple but allows a burst of up to 2x the limit straddling a window boundary. A sliding window (weighted average of the current and previous window, or a sorted-set of timestamps) bounds the burst more tightly at the cost of slightly more storage and computation per check. See Rate Limiting for the general algorithm comparison — this gateway applies the identical choice independently to RPM and TPM.
RPM and TPM reserved independently
A single reservation call checks and decrements two counters in the same atomic script: request count and token count. Both must succeed for the reservation to succeed; if either is insufficient, the whole reservation is denied and neither counter is touched (no partial decrement).
Fail-mode policy when the quota store is unavailable
If the shared counter store itself is down, the gateway has three honest choices, and must pick one explicitly rather than default accidentally: fail closed (reject all requests — safest for budget, worst for availability), fail open with a conservative local fallback limit (bounded risk, preserves availability), or fail open with no limit (never acceptable for a financial resource). Most production designs fail closed for budget/TPM and fail open with a strict local fallback for RPM, reflecting that a request-count overshoot is cheaper than a budget overshoot.
Token & Budget Reservation Mechanics
Reservation, settlement, and release are three distinct operations, each guarded by the reservation's status so that repeating any of them cannot charge or refund twice.
Reservation record
A reservation is a Redis hash stored next to the tenant's counters, keyed by the request ID: tenant, estimated input tokens, estimated output tokens (= requested cap), estimated USD, price version, status (RESERVED, SETTLED, RELEASED, PENDING_RECONCILIATION), a list of upstream attempts, created_at, and an expiry. A reservation that never settles, for example because the gateway process crashed mid-request, must not stay RESERVED forever, but it also must not simply be released: if an upstream call was started, the provider may have run it. The reconciler described below decides what to do.
Settlement
On completion the gateway does two things in a fixed order. First it inserts the usage record into the ledger, keyed uniquely by the reservation ID, so repeating it creates nothing new. Second it asks the quota store to settle: a single atomic script that checks the status is still RESERVED, marks it SETTLED, and refunds reserved minus actual to the tenant's counters. If the status is anything else, the script does nothing and returns the existing result, so a retried settle can never refund twice. These two steps are not one atomic operation, because the ledger and the quota store are different systems. The next sections explain what keeps them consistent.
Tokenizer and pricing drift
The estimate uses the tokenizer and price list in effect when the request was admitted. If pricing changes between reservation and finalization (a control-plane snapshot update mid-flight), the finalize step uses the price that was in effect at admission time, not the new one — otherwise a request's cost could change retroactively after the tenant already saw the estimate. This is a deliberate, documented choice, not an oversight.
Worked example
A request sets max_tokens=500. Reservation assumes the worst case — the full output cap — priced at realistic asymmetric per-token rates: input at $0.00003/token, output at $0.00012/token (completion tokens typically cost several times more than prompt tokens with most providers). Reserved = 1,500 input × $0.00003 + 500 output × $0.00012 = $0.045 + $0.06 = $0.105. The call actually completes after only 310 output tokens: actual = 1,500 × $0.00003 + 310 × $0.00012 = $0.045 + $0.0372 = $0.0822. The ledger finalizes at $0.0822 and releases $0.0228 of reserved budget — exactly the value of the 190 unused output tokens at the output rate — back to the tenant's quota.
What is atomic, and inside which boundary
Atomicity here is per store, never across stores. Inside the quota store, one Lua script on one shard (counters and reservation record share a hash tag so they live on the same shard) performs each of these as a single indivisible step: reserve (check RPM, TPM and budget, decrement all, create the record), settle (check status, mark SETTLED, refund the unused part), and release (check status, mark RELEASED, refund everything). Inside the ledger, one idempotent insert keyed by reservation ID is atomic. No transaction spans the ledger and the quota store. Consistency between them comes from three things instead: a fixed order of operations (ledger first, then quota store), status checks that make every quota-store step safe to repeat, and a reconciler that repairs the gap if the gateway dies between the two. The ledger is the system of record for money; the quota counters are fast enforcement state that can be corrected from it.
Crash recovery and reconciliation
Consider each point where a gateway replica can die. Before reserve completes: nothing was held, nothing to repair. After reserve, before any upstream call: the reservation expires, and because no attempt was ever recorded the reconciler releases it in full. After an attempt was recorded and before usage is known: the provider may have run it, so the reconciler does not release; it looks up the provider's usage report by the correlation ID and settles from that, and if no report arrives before a hard ceiling it escalates, leaving the worst case charged rather than quietly refunding it. After the ledger insert and before the quota-store settle: the reservation is still RESERVED while the ledger already holds the usage, so the reconciler finds the ledger row and runs the same settle script. Double charge is prevented because the ledger insert is unique on the reservation ID; double release is prevented because settle and release only act on a RESERVED reservation, so a second attempt finds a terminal status and changes nothing.
Retrying after a timeout the provider may have executed
A timeout does not mean the provider did nothing. It means the gateway stopped waiting. So the attempt is recorded as unknown usage, and a second attempt is allowed only if the quota store can extend the same reservation to cover a possible second execution; otherwise the gateway stops and returns an error instead of risking uncovered spend. Where the provider supports an idempotency key, the retry sends the same key so a duplicate execution is not started; where it does not, a retry can produce two generations and two charges, and both are accounted against the reservation. Only one response is ever delivered to the client. An outright refusal (429, 5xx, connection refused) is different: the provider reports that it did not run the request, so the retry costs nothing extra and uses the existing reservation as is. Even so, providers' billing behavior differs, so the reconciler compares final provider usage with the gateway's records and flags a mismatch.
| Field | Set at | Mutable after? |
|---|---|---|
| estimatedTokens | admission | no — historical record of what was reserved |
| actualTokens | finalization | no — set once, from provider usage report |
| reservationStatus | admission → finalization/expiry | yes — status and the attempt list change, by design |
| priceVersion | admission | no — locked to avoid retroactive cost changes |
Model Registry & Logical Models
The model registry is a policy object store, versioned and distributed through the control plane — not a model file, and not a live lookup against any provider's own model-listing API.
Logical model record
{ name: 'support-fast', deployments: [{provider:'A', model:'a-chat-v3', region:'us-east-1', capabilities:{streaming:true, tools:true, jsonMode:true, maxContext:128000}, priceVersion:'2026-10'}, {provider:'B', model:'b-chat-2', region:'eu-west-1', capabilities:{streaming:true, tools:true, jsonMode:false, maxContext:96000}, priceVersion:'2026-10'}] }
Capability documentation is part of the contract
'support-fast supports streaming' is only true if every deployment behind it supports streaming — the registry must document capability intersection, not just union, or callers will intermittently get capability-mismatch errors depending purely on which deployment the router happened to pick that time.
Deprecation and migration
Deprecating a specific provider model (not the logical name) is transparent to clients: remove or down-weight that deployment from the logical model's deployment list in a new control-plane snapshot. Clients calling 'support-fast' never change their code. This is the entire point of the indirection introduced in Complete Design Chapter 07.
Provider Adapters & Capability Contracts
An adapter is a typed translation layer: gateway-standard request/response shape on one side, a specific provider's native API shape on the other — with an explicit rejection path for anything it can't honestly translate.
interface ProviderAdapter:
translateRequest(req: GatewayRequest, deployment) -> ProviderRequest
# raises CapabilityUnsupported(field) if req needs something
# this deployment cannot honor — never silently drops a field
call(deployment, providerReq, stream: bool) -> ProviderResponse | AsyncIterator<Chunk>
translateChunk(chunk: ProviderChunk) -> GatewayChunk
# normalizes SSE/delta formats across providers to one chunk shape
extractUsage(response | finalChunk) -> {inputTokens, outputTokens, finishReason}
# the only source of truth for actual usage — never derived from
# the gateway's own token-counting of the request/response textStreaming chunk normalization
Different providers emit different SSE event shapes (delta-text vs. full-message-so-far, different field names for the finish reason, different tool-call streaming protocols). The adapter's translateChunk is where this gets normalized into one gateway-wide chunk shape, so the client-facing stream format never changes when a provider changes behind the scenes.
Usage extraction is provider-authoritative
Actual token usage always comes from the provider's own usage report in its response (or final streaming event), never computed by re-tokenizing the prompt and completion on the gateway side — different providers use different tokenizers, and a locally recomputed number would silently diverge from what the tenant is actually billed for (Invariant I7).
Routing: Filter and Score
Routing is a two-stage function: a hard filter that can only remove candidates, followed by a weighted score that can only rank the survivors.
function route(req, candidates, policy):
eligible = []
for d in candidates:
if not d.supportsResidency(req.tenant.requiredRegion): continue
if not d.supportsCapabilities(req.capabilities): continue
if d.circuitState == OPEN: continue
if not policy.vendorAllowed(d.provider): continue
eligible.append(d)
if eligible.isEmpty(): return NO_ELIGIBLE_DEPLOYMENT
score(d) = policy.wPrice * normalize(1 / d.pricePerToken)
+ policy.wLatency * normalize(1 / d.p50LatencyMs)
+ policy.wHealth * d.healthScore # 0..1, degrades on recent errors
+ policy.wCapacity * d.availableCapacityRatio
return argmax(eligible, score)Why filter can only remove, never add
If scoring ran first and filtering second, a disqualified deployment could theoretically win the score and only get caught afterward — exactly the ordering bug that causes a residency violation to reach a provider before anyone notices. Filtering first makes the disqualification a pre-condition of entering scoring at all, not a post-hoc check.
Health score vs. circuit breaker state
These are two different signals operating at two different granularities. Circuit state is binary-ish (closed/open/half-open) and gates eligibility entirely. Health score is continuous (0..1, based on recent error rate and latency percentile) and only affects ranking among still-eligible deployments — a deployment can have a mediocre health score and still be selected if every alternative is worse.
Circuit Breakers & Retry Budgets
Two independent mechanisms protect against a struggling upstream: a per-request retry budget (protects the caller's own latency) and a per-deployment circuit breaker (protects the struggling dependency from more load).
Retry budget
A single request gets a bounded number of upstream attempts (commonly one primary plus one or two alternates, with jittered backoff — see Retry & Backoff) and a total deadline that covers every attempt, so retries can never stretch the client's wait without limit. Failover is allowed only while no bytes have been committed to the client. A provider's Retry-After header is honored verbatim when present; otherwise backoff starts at a small base delay with full jitter. All attempts run under the one reservation made at admission; the next sections explain what that means for accounting when an attempt times out.
Circuit breaker state machine
CLOSED (normal — requests flow, failures counted in a rolling window) → OPEN (≥N consecutive or ≥X% failures — all requests to this deployment are skipped immediately, no network call attempted, for a cooldown period) → HALF-OPEN (cooldown elapsed — exactly one probe request is allowed through) → back to CLOSED on probe success, or OPEN again (with the cooldown possibly extended) on probe failure. See Circuit Breaker for the general pattern.
Per-deployment, not per-provider
Circuit state is tracked per deployment (a specific model + region + provider combination), not per provider company — Provider A's us-east-1 deployment can be open while Provider A's eu-west-1 deployment (if the tenant's policy even allows it) stays closed. Collapsing these would incorrectly take healthy capacity offline.
Streaming Architecture & Backpressure
Streaming turns the gateway into a long-lived, stateful-per-connection relay for the duration of one request — which changes resource accounting and failure handling compared to a one-shot request/response.
Flow control, not unbounded buffering
The gateway reads from the upstream provider connection only as fast as it can write to the client connection (or an internal bounded buffer with a hard cap). When the client is slow, this naturally slows the read from upstream too — standard backpressure, the same mechanism as Backpressure in any producer/consumer system, applied specifically to SSE/chunked relaying.
Concurrency slot accounting
A streaming request holds a concurrency slot (and its token/budget reservation) for the entire stream duration, not just the initial handshake. Slow streams — by far the ~10% long-tail minority — disproportionately hold slots, which is exactly why admission control must reason in terms of concurrent in-flight load (Little's Law), not just requests-per-second.
Partial-failure event contract
A mid-stream failure emits a distinct stream_error event type to the client (not a reused 'chunk' event with an error-shaped payload) carrying: chunks delivered so far, a reason code, and incomplete: true. This lets client UIs render 'response was cut off' affordances instead of silently truncating or retrying automatically in a way that could duplicate tool-call side effects.
Cancellation propagation
On client disconnect, the gateway closes its own upstream connection where the provider's protocol supports cooperative cancellation, and always releases the concurrency slot immediately regardless of whether the upstream cancel succeeds cleanly. The budget reservation is not released: it is marked PENDING_RECONCILIATION and stays charged at its worst case until the provider's usage report (which may arrive after the cancel) confirms the actual generated amount, at which point the normal ledger-then-settle sequence runs. A failure after the first byte follows the same path with whatever usage the provider reported.
Control Plane / Data Plane Separation
The data plane (gateways) never performs a synchronous call to the control plane inside a request's critical path. All policy, pricing, and credential changes arrive asynchronously, as versioned snapshots.
Snapshot versioning
Each policy snapshot carries a monotonically increasing version number. A gateway logs which version it's operating on with every request's telemetry — so an incident review can answer 'was this request affected by the policy bug introduced in v42' precisely, instead of guessing from timestamps.
Freshness window and its security trade-off
Gateways poll for (or receive a push of) the latest snapshot on an interval short enough to be operationally acceptable — commonly tens of seconds. A permission revoked between snapshots is honored only once the next snapshot lands; this is a deliberate, bounded staleness traded for removing the control plane from the hot path entirely. The bound must be documented and short enough for the actual threat model, not assumed to be zero.
Outage behavior
If the control plane is unreachable, gateways continue serving on their last successfully loaded snapshot indefinitely (or until an operator-configured max-staleness kill-switch, if one exists for the deployment). New tenant onboarding and policy changes are blocked until the control plane recovers; existing traffic is unaffected.
Error Status Semantics
Every status code the gateway returns carries a specific, consistent meaning a client can program against — mixing these up produces integration bugs that are hard to diagnose.
| Status | Meaning | Client should |
|---|---|---|
| 401 | Invalid or missing virtual key | Refresh/re-issue the credential; do not retry the same request as-is |
| 403 | Valid credential, disallowed model/capability | Do not retry — this is a policy decision, not a transient failure |
| 429 (gateway) | Tenant's own RPM/TPM/budget exhausted | Honor Retry-After; back off per-tenant, not globally |
| 429 (relayed from provider) | The provider's own aggregate limit was hit — can still happen even with per-tenant admission control, if the gateway's total outbound traffic across all tenants exceeds what the provider allots this account | Retry through the router against a different provider/deployment if one is eligible; otherwise back off and surface a capacity signal, not an auth error |
| 503 | No eligible deployment (all filtered out or circuits open) | Retry later with backoff; this is a capacity/compliance condition, not an auth problem |
| stream_error event | Mid-stream upstream failure after bytes committed | Render partial response as incomplete; do not auto-retry destructively |