Idempotency and reliability
Networks fail mid-flight. A request times out, your client never sees the 200 and you do not know whether the model already ran and charged the wallet. Ringside gives you three tools to make that safe. An idempotency-key replays a stored response instead of re-running the model. A model fallback cascade walks cheap-to-expensive on upstream failure. At-least-once webhook delivery covers the events you cannot poll for. This page covers how each one behaves, where they overlap and the exact retry rules to build into your client.
Why this exists
A chat completion is not free and it is not instant. It debits a wallet, meters usage and can take seconds against a slow upstream. So the classic at-least-once retry problem is real here: if your POST to /v1/chat/completions fails at the transport layer, retrying blindly risks paying for and storing two completions for one logical request.
The fix is an idempotency key. You attach a key you generated to the request. The first time Ringside serves a 2xx for that key, it stores the response body and status. Any later request with the same key inside the replay window short-circuits before the model runs and returns the stored response. The model executes once. The wallet is debited once. Your retry is safe.
Ringside records the response for idempotent replay only when the handler produced a 2xx. A 4xx or 5xx is never cached, so a failed call leaves no record and you can retry the same key freely until one attempt succeeds.
The idempotency-key header
The header name is exactly idempotency-key, lowercase. You choose the value. A UUIDv4 per logical operation is the usual pattern. The key is opaque to Ringside, so any string your client treats as unique per operation works.
| Property | Value |
|---|---|
| Header name | idempotency-key (lowercase) |
| Replay window | 24 hours from first successful store |
| Scope | (developer, customer, key) triple |
| What is stored | response body and HTTP status, on 2xx only |
| Supported on | chat completions, Assistants create, and the thread/run/message write endpoints; and Brains entry writes (PUT /v1/brains/{id}/entries), which additionally reject a reused key sent with a different body (idempotency_key_reuse, 409). Other write endpoints ignore the header today. |
Scope matters for tenant safety. The stored record is keyed by (developer, customer, key), not just (developer, key). The customer dimension is the attribution target: the pinned customer id when the call is attributed (a Client token, or a Bearer call carrying an FC-Customer header) or the empty string for an unattributed developer-direct call. Without that dimension one customer could send a key another customer already used under the same developer and read back the other customer's completion. The customer scope closes that.
Because the customer id is part of the scope, the same idempotency-key value attributed to cus_42 and to cus_99 produces two independent records. Reusing a key across customers does not collide and does not leak.
What a replayed response looks like
A replay returns the exact stored body with its stored status. It carries an X-Request-Id for the replay request itself, which differs from the original request's id because it is a fresh inbound request. The model is not called, no new usage event is recorded against the key claim and the wallet is not debited a second time.
If the original request set stream:true and an idempotency-key, Ringside stores the assembled completion as a non-streamed chat.completion object. A retry of that key returns that stored object as a single JSON 200, not an SSE event stream. Your retry path must accept a non-streamed body even when the first attempt streamed. Mid-stream client disconnects are treated like an error: the partial output is not stored, so the next retry re-runs the model.
The model fallback cascade
Idempotency makes a retry safe. The fallback cascade reduces how often you need one. Send model as an array instead of a string and Ringside walks the chain in order, calling the next model only when the current attempt fails for a reason worth escalating. Order the array cheap-to-expensive and you get a cheap primary that quietly upgrades to a stronger model when the cheap upstream is down or returns unusable output.
{
"model": ["fc:groq/llama-3.1-8b", "fc:openai/gpt-4o-mini", "fc:anthropic/claude-sonnet-4"],
"messages": [{ "role": "user", "content": "Summarize this contract clause." }],
"response_format": { "type": "json_object" }
}Each attempt is a real call against the underlying handler and records its own usage event, so your audit log shows precisely which models were tried and why each non-final attempt was skipped. A success returns immediately. The chain stops the moment an attempt returns something the cascade is not allowed to escalate past.
What triggers escalation
| Result | Action |
|---|---|
| HTTP 502 / 503 / 504 | Escalate. Transient or unavailable upstream, try the next model. |
Error code upstream_unavailable | Escalate. Provider-side failure. |
Error code response_schema_validation_failed (422) | Escalate. The model produced output that failed your json_schema. |
| HTTP 200 | Stop. Return the response with cascade headers. |
| 400 / 401 / 402 / 403 / 404 | Stop. Caller's problem (bad request, auth, budget, permission, not found). Returned as-is. |
| 429 (rate limit) | Stop. Ringside's own limiter fired; escalating would not help. |
If every model in the chain fails, Ringside returns the last failure response and adds X-Ringside-Fallback-Exhausted: true alongside the chain headers, so an exhausted cascade is distinguishable from a single-model failure.
Headers that report the cascade
- X-Ringside-Model-Used
- The model ref that served the response, echoed exactly as you sent it in the chain (so an
fc:ref comes backfc:-prefixed). Present on the success path. - X-Ringside-Models-Tried
- Comma-separated list of every model attempted, in order. Present on success and on a non-escalating failure.
- X-Ringside-Fallback-Triggered
trueif the response came from a later model in the chain (escalation happened),falseif the first model served it.- X-Ringside-Fallback-Exhausted
trueonly when the whole chain failed and the last error is being returned.
Mutual exclusivity
The cascade cannot combine with streaming or idempotency, and both rejections are deliberate rather than incidental.
| Combination | Result | Why |
|---|---|---|
model:[] + idempotency-key | 400 fallback_with_idempotency_unsupported | A cached record would capture the first attempt, masking the eventual success on replay. |
model:[] + stream:true | 400 fallback_with_stream_unsupported | Once the first SSE byte ships, the client has started consuming and Ringside can no longer fall back to a different model. |
Practically, you pick one tool per call. Use a cascade for a one-shot generation where automatic upgrade beats a manual retry. Use a single model plus an idempotency-key when you need exactly-once semantics on a retried operation. They solve different halves of the reliability problem and Ringside refuses to let them silently undermine each other.
A retry strategy
Build your retry logic around the error class, not the HTTP code alone, and always branch on the code field rather than the human-readable message. The rule is short. Retry transport failures and 5xx. Retry a 429 after waiting out its Retry-After. Never retry a 4xx validation error, because the same body will fail the same way every time.
| Status | Retryable? | How |
|---|---|---|
| Transport error / timeout | Yes | Reuse the same idempotency-key so a duplicate that did land is replayed, not re-run. |
| 500 / 502 / 503 / 504 | Yes | Exponential backoff with jitter. Same idempotency-key. |
429 rate_limit_error | Yes | Wait Retry-After seconds (also exposed as retry_after_seconds in the body), then retry. |
400 invalid_request_error | No | Fix the request. Retrying is wasted work. |
401 authentication_error | No | Fix the key or auth scheme. |
402 wallet_error | No | Top up the developer or customer wallet, then retry as a new operation. |
404 not_found_error | No | The id does not exist for this tenant (cross-tenant access also returns 404). |
409 conflict_error | Conditional | Resource-state conflict (e.g. thread_locked). Retry after the conflicting operation clears, not immediately. |
For any operation you would retry on a 5xx or timeout, generate one idempotency-key per logical operation and reuse it across all retries of that operation. That is the difference between safe at-least-once delivery and accidentally charging the wallet twice.
Webhooks: at-least-once
Some outcomes you cannot poll for cheaply, like a batch finishing overnight or a customer crossing its monthly budget. Those arrive as webhooks. Webhook delivery is at-least-once with retries, which is the mirror image of request idempotency: Ringside guarantees it will keep trying, so your endpoint must tolerate seeing the same event more than once.
- ·Deduplicate on the event id in your handler. A redelivery of an event you already processed should be a no-op.
- ·Verify the
X-FC-Signatureheader (t=<unix>,v1=<hex>) with a constant-time compare against the raw body, and reject stale timestamps. - ·A sustained delivery-failure streak auto-disables the endpoint (
statusbecomesdisabled_after_failures); inspectGET /v1/webhooks/{id}/deliveriesand re-enable once your receiver is healthy. - ·Replay a specific missed delivery with
POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry.
The full signature scheme, the rotation grace window (two v1= digests during rotation) and the event taxonomy are covered on the webhooks reference. The request.failed_after_retry event in particular fires when Ringside's own internal retries for a request are exhausted, which is a useful signal to wire into alerting.
Worked example
A single attributed chat completion with a stable idempotency-key, retried safely. The key is generated once per operation and reused on every retry. The first attempt times out at your client; the second lands and either runs the model or replays the stored 200 if the first attempt had in fact reached Ringside.
curl https://api.fightclub.pro/v1/chat/completions \
-H "Authorization: Bearer ko_<64-hex>" \
-H "Content-Type: application/json" \
-H "FC-Customer: cus_42" \
-H "idempotency-key: 6f1c2a90-3b7e-4d1f-9a2c-7e51b0c4d8a1" \
-d '{
"model": "fc:openai/gpt-4o-mini",
"messages": [{ "role": "user", "content": "Draft a one-line release note." }]
}'Now contrast the cascade form. Here the goal is automatic upgrade, not retry-safety, so there is no idempotency-key (the two cannot coexist) and stream is left at its default of false.
curl https://api.fightclub.pro/v1/chat/completions \
-H "Authorization: Bearer ko_<64-hex>" \
-H "Content-Type: application/json" \
-H "FC-Customer: cus_42" \
-d '{
"model": ["fc:groq/llama-3.1-8b", "fc:openai/gpt-4o-mini", "fc:anthropic/claude-sonnet-4"],
"messages": [{ "role": "user", "content": "Classify the sentiment." }]
}'
# On a Groq outage the response carries, for example:
# X-Ringside-Model-Used: fc:openai/gpt-4o-mini
# X-Ringside-Models-Tried: fc:groq/llama-3.1-8b,fc:openai/gpt-4o-mini
# X-Ringside-Fallback-Triggered: trueDefaults and limits
- ·Idempotency replay window: 24 hours from the first successful store.
- ·Idempotency scope:
(developer, customer, key); the customer dimension is empty for unattributed developer-direct calls. - ·Stored bodies cover 2xx only; 4xx and 5xx are never cached.
- ·
modelaccepts a string or a non-empty array; an array drives the cost cascade. - ·A cascade plus
stream:truereturns 400fallback_with_stream_unsupported; a cascade plusidempotency-keyreturns 400fallback_with_idempotency_unsupported. - ·Webhook delivery is at-least-once; a sustained failure streak sets the endpoint to
disabled_after_failures.
Related
The four enforcement layers, X-RateLimit-Remaining and how Retry-After drives your 429 backoff.
Signature verification, the rotation grace window, the event taxonomy and delivery retries.
The error body shape, every type and code, plus why you branch on code not message.
Every request and response header, body parameter and the model-ref grammar.