Rate limiting and quotas
Ringside enforces request and token rate limits at four layers, and the lowest applicable limit always wins. This page explains the mental model, the exact RPM and TPM windows, the response headers you get back (and the ones you will not), and how to handle a 429 with backoff that honours Retry-After.
Why four layers exist
Ringside sits between your application and 19 upstream providers, and it bills against a wallet on every call. Rate limiting has to protect the shared edge from a single noisy source IP, your developer account from runaway spend and each of your customers from the others all at once. A single per-account limit cannot express that. So enforcement is split into four independent layers, evaluated in order, and the first layer that says no is the one that returns the 429.
The layers are not additive and they do not negotiate. Each holds its own counter. When you mint a client token with rpm_limit: 600 for a customer whose rpm_limit is 120, the token is clamped to 120 at mint time, and even then the per-customer layer can still reject the request if other tokens for the same customer have already filled the window. Think of it as the minimum of every limit that applies to a given request.
The four enforcement layers
| Layer | Keyed by | Window | What it protects | Error code on 429 |
|---|---|---|---|---|
| 1. Edge per-IP | Source IP | Sliding 60s | The shared edge from a single abusive client | rate_limit_error |
| 2. Per-client-token | Token jti (ctok_) | Sliding 60s | One browser session from exhausting a customer | client_token_rate_limit_exceeded |
| 3. Per-developer | Your ko_ key / dev account | Sliding 60s | Your whole account from runaway request volume | rate_limit_error |
| 4. Per-customer | Customer id (cus_) | RPM: sliding 60s. TPM: rolling 60s. | One of your customers from starving the others | customer_rate_limit_exceeded |
A developer ko_ key sent as Authorization: Bearer is never subject to the per-client-token layer. That layer only applies to browser-minted client tokens, sent as Authorization: Client <jwt>. A Bearer request is evaluated against the edge, developer and customer layers only.
RPM versus TPM
The per-customer layer is the one you configure directly, and it has two dimensions. RPM caps requests per minute. TPM caps tokens per minute. They are set as rpm_limit and tpm_limit on the customer record, and either can be left null to disable that dimension. A value that is null or less than or equal to zero is treated as off.
- rpm_limit
- Requests per minute for this customer, enforced as an in-memory sliding window of request timestamps over the trailing 60 seconds. A request is counted the moment it passes the check, so the check is a commit, not a preview.
- tpm_limit
- Tokens per minute for this customer, enforced as a rolling 60-second SUM of input_tokens + output_tokens over completed (status ok) usage events, compared against that running total plus the estimated input and output tokens of the incoming request.
The two windows differ in a way that matters when you reason about retries. RPM is a true sliding window held in process: each accepted request appends a timestamp, and the window is the last 60 seconds at the instant of the check. TPM is computed from the usage ledger, so it only sees tokens from requests that already finished and recorded status = 'ok'. An in-flight burst of large completions can therefore pass the TPM check on the way in and only push you over once they settle.
How TPM estimates the incoming request
Before dispatch, Ringside adds the estimated input and output tokens of the new request to the rolling 60-second sum. If used + estimated > tpm_limit, the request is rejected with limit_type tpm. Because the estimate is added pre-flight, a single request whose estimated tokens alone exceed the remaining budget will be blocked outright rather than partially admitted.
Response headers
Ringside advertises remaining request budget on the response, but it is deliberately minimal. There is exactly one steady-state header and one extra header on a 429. Casing is exact and must be matched case-insensitively by your client (HTTP header names are case-insensitive, but the wire casing below is what Ringside emits).
| Header | When present | Value |
|---|---|---|
X-RateLimit-Remaining | On any response, only when the attributed customer has an rpm_limit configured | Remaining requests in the current 60s RPM window. 0 on a rate-limited response. |
Retry-After | Only on a 429 | Integer seconds until you may retry. Always at least 1. |
Ringside does not emit X-RateLimit-Limit or X-RateLimit-Reset. Do not parse for them. The only signals are X-RateLimit-Remaining (request count, RPM-only) and, on a 429, Retry-After (integer seconds). X-RateLimit-Remaining is a request count and never reflects TPM, because tokens and requests have different units.
One subtlety: X-RateLimit-Remaining is omitted entirely when the customer has no rpm_limit set, rather than sent as some sentinel like -1. Code that reads the header must treat absence as unlimited, not as zero.
Client-token RPM defaults and the ceiling
Client tokens carry their own RPM, set at mint time via POST /v1/customers/{id}/client_tokens. If you omit rpm_limit, the token gets 60 requests per minute. The hard ceiling is 600, and on top of that the value is clamped to the customer's rpm_limit if one is set.
| Property | Value | Behaviour at mint |
|---|---|---|
Default rpm_limit | 60 | Applied when you omit the field |
Maximum rpm_limit | 600 | Above this returns 400 invalid_rpm_limit |
| Effective ceiling | min(600, customer rpm_limit) | Above the customer cap returns 400 rpm_exceeds_customer_cap |
Minimum rpm_limit | 1 | 0 or negative returns 400 invalid_rpm_limit |
At request time the per-token limiter runs its own sliding 60s window keyed by the token jti. If the registry row somehow has no usable limit, the limiter falls back to 60 rather than letting traffic through unbounded. So a missing limit never disables the layer.
What a 429 looks like
Every rate-limit rejection uses the standard Ringside error envelope. type, code and message are always present, and rate-limit errors add retry_after_seconds. Always branch on code, never on the human-readable message. The request id is in the X-Request-Id response header, never in the body.
{
"error": {
"type": "rate_limit_error",
"code": "customer_rate_limit_exceeded",
"message": "Customer rpm limit exceeded",
"retry_after_seconds": 17
}
}Note that retry_after_seconds in the body and the Retry-After response header carry the same integer. The body field is convenient when you have already deserialized the error; the header is convenient in middleware that never reads the body. Either is authoritative.
The three rate-limit codes
| code | type | Layer | Extra signal |
|---|---|---|---|
rate_limit_error | rate_limit_error | Edge per-IP or per-developer | Retry-After header |
client_token_rate_limit_exceeded | rate_limit_error | Per-client-token | Retry-After header + retry_after_seconds body |
customer_rate_limit_exceeded | rate_limit_error | Per-customer (RPM or TPM) | Retry-After header + retry_after_seconds body |
Both the RPM and TPM dimensions of the per-customer layer surface as customer_rate_limit_exceeded. The distinction (which dimension tripped) is internal. Your retry logic does not need it: the retry_after_seconds already encodes the right wait for whichever window is full.
Handling a 429 correctly
The rule is short. On a 429, sleep for the seconds Ringside gives you, then retry. Do not invent your own constant backoff, and do not retry immediately. The server computes retry_after_seconds as the time until the oldest counted request leaves the window (for RPM) or a safe 60 seconds (for the TPM rolling sum), so honouring it lands you exactly at the moment a slot frees up.
- 01Read the status. A 429 means rate-limited; a 402 (
customer_budget_exceeded,customer_wallet_empty,wallet_empty) means out of money, which is not retryable without topping up. - 02Read
Retry-Afterfrom the response header (integer seconds), orretry_after_secondsfrom the error body. They match. - 03Sleep for that many seconds, with a small jitter if you have many workers, to avoid a thundering herd at the same instant.
- 04Retry the same request. If you are using an
idempotency-key, the retry is safe and will not double-charge within the 24h replay window. - 05Cap retries. After a few attempts, surface the error to the caller rather than looping forever.
curl -i https://api.fightclub.pro/v1/chat/completions \
-H "Authorization: Bearer ko_<key>" \
-H "FC-Customer: cus_42" \
-H "Content-Type: application/json" \
-d '{
"model": "fc:openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "ping"}]
}'
# HTTP/1.1 429 Too Many Requests
# X-Request-Id: req_8f31c0a2b9e44d1a
# X-RateLimit-Remaining: 0
# Retry-After: 17Watch X-RateLimit-Remaining on successful responses. When it trends toward 0, slow your send rate before you hit a 429. The official Ringside SDKs do this for you and retry transparently on retry_after_seconds, so you rarely need to write the loops above by hand.
Edge cases worth knowing
- ·A client token minted with
rpm_limitabove the customer cap is rejected at mint time with 400rpm_exceeds_customer_cap, not silently clamped. Clamping to the customer rpm is something you should do in your mint call, not rely on the server to swallow. - ·TPM can reject a request that RPM allowed, and vice versa. They are independent dimensions of the same per-customer layer; either one tripping returns
customer_rate_limit_exceeded. - ·Because TPM reads the usage ledger, a burst of large concurrent completions can each pass the inbound check and only collectively push the next request over. Size your
tpm_limitwith headroom for in-flight work. - ·
X-RateLimit-Remainingreflects only the per-customer RPM window. If a different layer (edge, developer or client-token) is what rejects you, the remaining header you saw on the prior success does not predict that. - ·A 402 is not a rate limit.
customer_budget_exceeded,customer_wallet_emptyandwallet_emptymean spend, not throughput, and retrying without topping up will fail again.
The RPM windows (edge, client-token and customer) are held in process. A web-tier restart clears them, so a deploy can briefly let through requests that a continuously-running instance would have throttled. The TPM dimension reads the durable usage ledger and is unaffected. Do not treat RPM as a hard security boundary; treat it as a throughput governor.
Defaults and limits at a glance
| Setting | Default | Bound |
|---|---|---|
Customer rpm_limit | null (off) | Any positive integer; null or <= 0 disables |
Customer tpm_limit | null (off) | Any positive integer; null or <= 0 disables |
Client-token rpm_limit | 60 | 1 to 600, clamped to customer rpm |
| RPM window | n/a | Sliding 60s |
| TPM window | n/a | Rolling 60s over completed usage events |
| Client-token mint rate | n/a | 100 mints/min per customer |
| Min Retry-After | n/a | 1 second |
Related
The full error envelope, every type and code, and why you branch on code not message.
Minting browser tokens, the rpm_limit / origin_allowlist / bind_ip options and the Client auth scheme.
Idempotency keys, retry strategy and building clients that survive 429s and 402s.
Every request and response header on POST /v1/chat/completions, including the rate-limit headers.