Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// reference · 10 min read

Error catalog

Every error Ringside returns, the envelope it arrives in and the eight type families that group it. The complete code list is grouped by area (auth, customers, chat, rate limits, wallet, vector stores, webhooks, assistants and runs, batch, files) with the HTTP status, the type and exactly how to handle each one. Branch on code, not on the human message, and log the X-Request-Id response header on every failure.

The mental model

Every non-2xx response from https://api.fightclub.pro/v1 carries a single JSON object with one top-level error key. The shape is stable across all endpoints. You read three fields on every error and two more on specific families. The type tells you the broad class and usually maps 1:1 to the HTTP status. The code is the stable machine-readable string you branch on. The message is human text for your logs and can change without notice.

Branch on code, never on message

The message field is free text tuned for a human reading a log line. It is not part of the API contract and we reword it whenever it reads better. The code is the contract. Write if (err.code === 'wallet_empty'), never a substring match on the message.

The error envelope

Three fields are always present: type, code and message. param appears only on field-validation errors and names the offending request field. retry_after_seconds appears only on rate-limit errors and mirrors the integer Retry-After response header.

{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_model_ref",
    "message": "model is not a fc:/match:/slot:/dyn: ref.",
    "param": "model"
  }
}
type
Always present. One of the eight families below (plus not_implemented for endpoints still on the roadmap). Maps to the HTTP status in almost every case.
code
Always present. The stable identifier you branch on. Unique per failure mode.
message
Always present. Human-readable detail for logs. Not a stable contract; do not parse it.
param
Present on field-validation errors only. The name of the request field that failed (model, messages, metadata, etc.).
retry_after_seconds
Present on rate-limit errors only. Integer seconds to wait before retrying. Mirrors the Retry-After header on the same response.

The request id

Every response, success or failure, carries an X-Request-Id response header (value prefix req_). It is a header, never a body field. Capture it on failures and quote it in support requests. If you set your own X-Request-Id on the request, Ringside echoes yours back so you can correlate against your own traces.

# Pull the request id off any response
curl -sD - https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer ko_REDACTED" \
  -H "Content-Type: application/json" \
  -d '{"model":"fc:openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' \
  | grep -i '^x-request-id:'
# x-request-id: req_8f2c1a...

Type families

Eight families cover the catalogue, with not_implemented reserved for roadmap endpoints. The family is a coarse signal: it tells your retry logic whether the failure is yours to fix (4xx) or ours to recover from (5xx). For anything beyond a retry/no-retry decision, switch on the code.

typeUsual HTTPWhat it meansRetry?
authentication_error401The Authorization header is missing, malformed or the key/token is invalid, revoked or expired.No. Fix the credential.
invalid_request_error400 (also 404, 413, 422)A field, header or body is wrong: bad shape, missing required field, over a limit, unsupported value.No. Fix the request.
permission_error403The credential is valid but lacks the scope, or a client token tried something only a developer key may do.No. Grant scope or use a developer key.
rate_limit_error429One of the four rate-limit layers fired. Read retry_after_seconds / Retry-After.Yes, after the advertised wait.
conflict_error409The resource is in a state that forbids the operation: name taken, thread locked, run already terminal, file in use.Sometimes, once the conflicting state clears.
not_found_error404No such resource. Also returned for cross-tenant access so ids are not enumerable.No.
payment_required402A wallet or budget is exhausted: developer wallet, customer prepaid wallet or customer monthly budget.No. Top up or raise the budget.
api_error500, 503Something on our side failed: a provider was unreachable, no provider key is configured or an internal error.Yes, with backoff.
not_implemented501The endpoint exists in the spec but is not live yet (e.g. POST /v1/realtime).No.
Cross-tenant access returns 404

Reading a cus_, vs_, asst_, batch_ or any other id that belongs to a different developer returns not_found_error (404), never permission_error (403). The 404 is deliberate so an attacker cannot enumerate which ids exist.

wallet_error is a code, not a type

The wallet write paths (/wallet/topup, /wallet/adjust) can return code: wallet_error with type: api_error and a 500 when the ledger write fails server-side. The day-to-day "out of money" cases are wallet_empty, customer_wallet_empty and customer_budget_exceeded, all type: payment_required at 402.

Complete code reference

Grouped by area. Status is the HTTP code, type is the envelope family. Codes shared across many endpoints (invalid_api_key, not_found, malformed_json, metadata_too_large) are listed once under the area where you hit them first, but they behave identically everywhere.

Authentication and authorization

codeHTTPtypeMeaningHow to handle
missing_token401authentication_errorNo Authorization header on the request.Send Authorization: Bearer ko_... (developer key) or Authorization: Client <jwt> (browser client token).
invalid_auth_scheme401authentication_errorAuthorization header did not start with Bearer or Client .Use one of the two schemes. Bearer for ko_ keys, Client for client-token JWTs.
invalid_token_format401authentication_errorA Bearer token that does not start with the ko_ prefix.Use a real developer key from /app/api-keys. Format is ko_ plus 64 hex chars.
invalid_token401authentication_errorThe token is unrecognised, revoked or expired.Rotate the key in the dashboard, or re-mint the client token.
invalid_api_key401authentication_errorPer-endpoint surface of a missing or revoked key/token (the form the endpoint reference advertises).Same as invalid_token: fix the credential.
invalid_client_token401authentication_errorThe Client-scheme JWT is empty, failed signature verification, was revoked or is unknown.Mint a fresh token via POST /v1/customers/{id}/client_tokens (the ttl is 60..3600s, default 900).
origin_not_allowed403permission_errorA client token with an origin_allowlist was used from an Origin not on the list.Add the Origin when minting, or call from an allowed Origin.
ip_binding_violation403permission_errorA bind_ip client token was used from a different client IP.Mint per client IP, or drop bind_ip for roaming clients.
endpoint_not_allowed_for_client_token403permission_errorA client token hit a developer-only endpoint (e.g. /v1/files, or revoking another client token).Call developer-only endpoints from your server with the ko_ key, not the browser.
insufficient_scope403permission_errorThe credential is valid but lacks the required scope (e.g. minting client tokens needs api:client_tokens).Grant the scope to the key, or use a key that holds it.
client_token_rate_limit_exceeded429rate_limit_errorThe per-client-token rpm window is exhausted. Carries retry_after_seconds.Back off until the advertised retry, then resume. Raise the token rpm at mint time (max 600, clamped to the customer rpm).

Customers

codeHTTPtypeMeaningHow to handle
invalid_rpm_limit400invalid_request_errorrpm_limit is not a positive integer (also: client-token rpm_limit outside 1..600).Send a positive integer.
invalid_tpm_limit400invalid_request_errortpm_limit is not a positive integer.Send a positive integer.
invalid_monthly_budget_usd400invalid_request_errormonthly_budget_usd is negative or not a number.Send a non-negative number, or omit for no budget.
invalid_default_billable_markup_pct400invalid_request_errordefault_billable_markup_pct is negative or not a number.Send a non-negative number.
invalid_default_billable_amount_usd400invalid_request_errordefault_billable_amount_usd is negative or not a number.Send a non-negative number.
billable_defaults_mutex400invalid_request_errorYou set both default_billable_markup_pct and default_billable_amount_usd. They are mutually exclusive.Set at most one of the two.
empty_patch400invalid_request_errorA defaults PATCH carried none of the updatable fields.Include at least one field to change.
invalid_lazy_create_customers400invalid_request_errorlazy_create_customers is not a boolean.Send true or false.
metadata_too_large400invalid_request_errormetadata over 4 KB serialized (customers and vector stores; conversations cap at 2 KB).Trim metadata. Limits: 16 keys, key <=64 chars, value <=512 chars.
invalid_metadata400invalid_request_errormetadata is not a JSON object, or breaks the key/length limits.Send a flat object inside the limits above.
external_id_taken409conflict_errorYou already have a customer with that external_id.Reuse the existing customer, or pick a different external_id.
customer_not_found404not_found_errorThe FC-Customer id/external id is unknown and lazy-create is off (or FC-Customer-Strict: true was set).Create the customer first, or drop strict mode to lazy-create.
not_found404not_found_errorNo customer with that id or external id. Also returned for cross-tenant access.Check the id. A 404 here does not confirm the id exists for someone else.
invalid_status400invalid_request_errorA conversation list filter status was not active, locked or archived.Use one of the three values.
invalid_status_transition400invalid_request_errorA conversation PATCH tried to set status to something other than active or archived (you cannot write locked).Only move between active and archived. locked is managed by the lock primitive.
metadata_too_large (conversation)400invalid_request_errorConversation metadata exceeds the 2 KB serialized cap.Trim conversation metadata to under 2 KB.
conversation_full409conflict_errorThe conversation/thread hit the 5,000-message cap.Start a new conversation.
conversation_locked409conflict_errorA message request holds the conversation lock (TTL 10 min).Retry in ~10 s once the in-flight turn finishes.

Chat completions

codeHTTPtypeMeaningHow to handle
invalid_json400invalid_request_errorThe request body is not valid JSON (also surfaced as malformed_json on some endpoints).Send valid JSON with Content-Type: application/json.
missing_model400invalid_request_errormodel is absent or not a string/array.Send a model ref or an array of refs.
missing_messages400invalid_request_errormessages is absent or empty.Send a non-empty messages array.
invalid_model_ref400invalid_request_errormodel is a bare name, or carries an unknown @region suffix. Every ref needs a fc: / match: / slot: / dyn: prefix.Prefix it, e.g. fc:openai/gpt-4o-mini. Region suffixes attach to fc: refs only.
invalid_model_for_chat400invalid_request_errorThe ref resolved to a model that cannot do chat completions.Pick a chat-capable model.
unknown_slot400invalid_request_errorA slot:<alias> ref names an alias you have not defined.Define the slot, or use a fc:/match: ref.
slot_cycle400invalid_request_errorA slot: alias resolves through a cycle.Break the alias loop in your slot config.
moderation_flagged400invalid_request_errorThe prompt or completion failed inline moderation (the chat moderation param).Drop or rewrite the flagged content. Pre-screen with POST /v1/moderations (free).
streaming_with_schema_fallback_unsupported400invalid_request_errorresponse_format json_schema requested together with stream: true.Stream without a schema, or use a schema without streaming.
fallback_with_stream_unsupported400invalid_request_errorA model: [...] cost cascade combined with stream: true.Stream with a single model, or run the cascade non-streamed.
fallback_with_idempotency_unsupported400invalid_request_errorA model: [...] cascade combined with an idempotency-key.Use a single model for idempotent calls.
response_schema_validation_failed422invalid_request_errorOutput did not satisfy response_format.json_schema after the re-prompt retries.Loosen the schema, raise max_tokens or switch model.
platform_not_configured503api_errorNo upstream provider key is configured for the resolved model.Pick a model whose provider is configured. Retry is pointless until config changes.
upstream_unavailable503api_errorThe provider errored and no fallback in the cascade succeeded.Retry with backoff, or add a model: [...] fallback chain.
internal_error500api_errorAn unhandled server-side failure.Retry with backoff. Quote the X-Request-Id if it persists.

The embeddings and rerank endpoints reuse the same auth and wallet codes plus their own validation: missing_input, invalid_input and input_too_large on embeddings (array <=2,048 items, each <=100,000 chars), and unsupported_rerank_model and too_many_documents on rerank (<=1,000 documents). Moderation only returns invalid_api_key, invalid_json and missing_input, and meters at $0.

Rate limits

Four enforcement layers run, lowest ceiling wins: edge per-IP, per-client-token, per-developer and per-customer. A 429 always carries an integer Retry-After header, and the body carries retry_after_seconds. Successful responses carry X-RateLimit-Remaining. There is no X-RateLimit-Limit or X-RateLimit-Reset.

codeHTTPtypeMeaningHow to handle
edge_rate_limit_exceeded429rate_limit_errorThe Layer 0 edge limiter (keyed by IP plus token, so it covers the per-IP and per-developer ceilings) fired before the request reached a route handler.Sleep for Retry-After seconds, then retry. Spread load.
customer_rate_limit_exceeded429rate_limit_errorThe attributed customer hit its rpm_limit or tpm_limit.Back off per retry_after_seconds. Raise the customer caps if the workload is legitimate.
client_token_rate_limit_exceeded429rate_limit_errorThe per-client-token rpm window is exhausted.Back off. Mint with a higher rpm_limit (max 600, clamped to the customer rpm).
mint_rate_limit429rate_limit_errorMore than 100 client-token mints/min for one customer.Throttle minting. Cache and reuse tokens up to their ttl.
lazy_create_rate_exceeded429rate_limit_errorToo many customers auto-created via the FC-Customer header in a short window.Bulk-create with POST /v1/customers instead of relying on lazy-create.

Wallet and budget (402)

codeHTTPtypeMeaningHow to handle
wallet_empty402payment_requiredYour developer wallet balance is zero or insufficient for the call.Top up the developer wallet.
customer_wallet_empty402payment_requiredThe attributed customer's prepaid wallet is zero or insufficient.Top up via POST /v1/customers/{id}/wallet/topup.
customer_budget_exceeded402payment_requiredThe attributed customer spent its monthly_budget_usd for the period.Raise the budget, or wait for the next month.
vector_store_suspended402payment_requiredA vector store is suspended for non-payment.Settle the balance to reactivate the store.
invalid_amount400invalid_request_errorA wallet adjust amount was zero or not a finite number.Send a non-zero finite number.
amount_too_large400invalid_request_errorA wallet adjust magnitude exceeded the per-call cap.Split the adjustment into smaller amounts.
wallet_error500api_errorA wallet ledger write failed server-side (topup/adjust).Retry with backoff. The id-empotent retry is safe if you send the same idempotency-key.

Every metered response (success or some 402s) carries X-Ringside-Wallet-Balance and X-Ringside-Wallet-Deducted so you can watch the balance fall and pre-empt the empty-wallet 402 before it bites.

Vector stores and RAG

codeHTTPtypeMeaningHow to handle
invalid_name400invalid_request_errorname is empty.Send a non-empty name.
name_too_long400invalid_request_errorname exceeds 256 bytes.Shorten the name.
missing_embedding_model400invalid_request_errorNo embedding_model on create, or on a migrate call.Set embedding_model (fixed at create; change via POST .../migrate).
invalid_embedding_model400invalid_request_errorembedding_model is not in the supported set.Pick a supported embedding model.
invalid_dimensions400invalid_request_errordimensions is not a positive integer.Send a positive integer, or omit for the model default.
invalid_encryption400invalid_request_errorencryption is not none, managed or byok.Use one of the three values.
invalid_vector_sealing400invalid_request_errorvector_sealing is not none, source or full.Use one of the three. Defaults to full when sealed.
invalid_vector_index400invalid_request_errorvector_index is not auto, flat or ivf.Use one of the three.
ivf_requires_encryption400invalid_request_errorvector_index: ivf requested on an unsealed store.Use ivf only on a sealed (encrypted) store.
byok_key_required400invalid_request_errorencryption: byok without a key object.Provide key: {type: passphrase, passphrase}.
byok_passphrase_too_short400invalid_request_errorThe byok passphrase is under 12 characters.Use a passphrase of 12+ chars.
byok_kms_unavailable400invalid_request_errorThe byok KMS backend is not configured or reachable.Retry once KMS is available, or switch to managed encryption.
not_a_byok_store400invalid_request_errorAn unlock/key call against a store that does not use byok.Only present a key on byok stores.
invalid_passphrase / missing_passphrase400invalid_request_errorIncorrect or absent passphrase when taking a key lease.Provide the correct key: {passphrase}.
vector_store_locked409conflict_errorA byok store has no active key lease.Take a lease via POST .../unlock or send the FC-Vector-Store-Key header.
vector_store_unavailable409conflict_errorThe store is in a deleting or deleted state.Do not write to a store being deleted.
vector_store_not_found404not_found_errorNo such store. Also returned for cross-tenant access.Check the vs_ id.
vector_store_file_not_found / file_batch_not_found404not_found_errorThe file row or file batch is not attached to this store.Check the id belongs to the store.
file_not_found404not_found_errorA referenced file_id does not belong to you.Upload the file first, or fix the id.
file_not_cancellable / file_not_retryable409conflict_errorA cancel/retry hit an ingest file in the wrong state.Cancel only pending/in_progress; retry only failed/cancelled.
invalid_file_ids / too_many_files400invalid_request_errorA file batch had an empty file_ids, or more than 500 files.Send a non-empty array of <=500 files.
migration_not_found404not_found_errorUnknown migration id on a rollback.Check the migration id.

Webhooks

codeHTTPtypeMeaningHow to handle
invalid_url400invalid_request_errorurl is missing or empty.Send a non-empty https URL.
insecure_webhook_url400invalid_request_errorurl is not https.Use an https endpoint.
malformed_webhook_url400invalid_request_errorurl is unparseable or resolves to a private / SSRF-unsafe host.Use a public, resolvable https URL.
empty_events400invalid_request_errorevents is missing or an empty array.Subscribe to at least one event type.
unknown_event_type400invalid_request_errorevents contains a type Ringside does not emit (e.g. a reserved run.* type).Use a type from the published taxonomy.
invalid_rotation_grace400invalid_request_errorrotation_grace_minutes is out of range.Send a value inside the allowed range.
webhook_url_in_use409conflict_errorYou already registered this url. URLs are unique per developer.Reuse the existing endpoint, or use a different url.
invalid_status_transition400invalid_request_errorA PATCH tried to move status somewhere other than active <-> paused (disabled_after_failures is worker-managed).Only flip between active and paused.
delivery_not_retryable409conflict_errorA manual retry against a delivery that is not in the dead state.Only retry dead deliveries.
not_found404not_found_errorNo such endpoint or delivery. Also cross-tenant.Check the wh_ id.

These are errors from the webhook management API. They are separate from delivery signature verification, which happens on your receiver: validate the X-FC-Signature header (t=<unix>,v1=<hex>) constant-time, accept either digest during a rotation grace and reject stale timestamps. See Receive and verify webhooks.

Assistants, threads and runs

codeHTTPtypeMeaningHow to handle
invalid_model400invalid_request_errormodel is missing or not a valid ref (assistant create, or run override).Send a valid fc:/match:/slot:/dyn: ref.
invalid_tools / invalid_tool_schema / invalid_tool_shape400invalid_request_errortools is not an array of valid tool objects, or a function tool's parameters schema is malformed.Send {type: file_search} or {type: function, function} objects with valid JSON Schema.
invalid_temperature / invalid_top_p400invalid_request_errortemperature outside 0-2, or top_p outside 0-1.Clamp to the valid range.
invalid_name400invalid_request_errorAssistant name is not a valid string.Send a valid name.
assistant_name_in_use409conflict_errorYou already have an active assistant with this name.Reuse it, or pick a unique name.
invalid_messages400invalid_request_errorA seed thread message is malformed.Fix the message shape.
thread_full409conflict_errorAppending would exceed the 5,000-message thread cap.Start a new thread.
thread_locked409conflict_errorA run is already active on the thread (only one active run per thread).Wait for the run to reach a terminal state before writing or starting another.
invalid_assistant_id / invalid_additional_messages400invalid_request_errorA run's assistant_id or additional_messages is missing or malformed.Fix the run body.
invalid_tool_call_id400invalid_request_errorsubmit_tool_outputs referenced an unknown tool_call_id.Answer only the tool_call_ids the run paused on.
run_not_in_requires_action409conflict_errorsubmit_tool_outputs called while the run is not waiting for tool outputs.Only submit when status is requires_action.
run_already_completed409conflict_errorCancel called on a run already in a terminal state.No action; the run is done.
not_found404not_found_errorNo such assistant, thread or run. Also cross-tenant.Check the asst_ / thread_ / run_ id.

Batch

codeHTTPtypeMeaningHow to handle
missing_input_file_id400invalid_request_errorinput_file_id is absent.Upload a JSONL with purpose=batch first, then pass its id.
invalid_endpoint400invalid_request_errorendpoint is not /v1/chat/completions, /v1/embeddings or /v1/moderations.Use one of the three supported targets.
invalid_completion_window400invalid_request_errorcompletion_window is not 24h.Send 24h (the only supported window).
input_file_wrong_purpose400invalid_request_errorThe input file was not uploaded with purpose=batch.Re-upload the file with purpose=batch.
metadata_too_large / metadata_too_many_keys400invalid_request_errormetadata over 4 KB, or more than 16 keys.Trim metadata. Max 16 keys, key <=64 chars, value <=512 chars.
batch_not_cancellable409conflict_errorCancel against a batch that is not in a cancellable state.Cancel only while the batch is running.
not_found404not_found_errorNo such batch. Also cross-tenant.Check the batch_ id.

A batch caps at 50,000 requests. Failed lines land in error_file_id; download both output_file_id and error_file_id via GET /v1/files/{id}/content.

Files

codeHTTPtypeMeaningHow to handle
missing_purpose400invalid_request_errorpurpose is required and absent.Send purpose (e.g. batch, attachments, vision).
invalid_purpose400invalid_request_errorpurpose is not an allowed value.Use a supported purpose.
missing_file / missing_filename400invalid_request_errorNo file part in the multipart body, or the part has no filename.Send a multipart/form-data body with a named file part.
invalid_multipart400invalid_request_errorThe body is not valid multipart/form-data.Send a proper multipart upload.
file_too_large413invalid_request_errorOver the 512 MB per-file cap.Split or shrink the file.
file_in_use409conflict_errorDELETE blocked: the file is still referenced (by a vector store or batch).Delete or cancel the referencing batch/store first.
not_found404not_found_errorNo such file. Also cross-tenant.Check the file_ id.
Media size limits that surface as invalid_request_error

Inline media on chat is bounded too: images 20 MB (max 2 per message), PDFs/docs 20 MB (max 2 per message), audio 25 MB, and a serialized message body caps at 100,000 bytes. Over a limit you get an invalid_request_error (content_part_too_large, content_part_limit_exceeded, content_part_file_not_found or content_parts_unsupported_by_model).

Brains

codeHTTPtypeMeaningHow to handle
resource_not_found404not_found_errorThe brain (or entity) does not exist, is not yours, or the feature is disabled. Cross-tenant collapses to this.Check the brn_ id and that Brains is enabled for your account.
missing_query400invalid_request_errorquery is absent on recall.Send a non-empty query.
missing_scope400invalid_request_errorscope is required on GET /scopes and GET /entities and was absent.Pass a scope query param; it bounds the listing to a subtree.
missing_target400invalid_request_errorforget got none of scope/source/entity.Provide exactly one target.
missing_body400invalid_request_errorAn entry write had no body.Send a non-empty body.
entry_too_large413invalid_request_errorAn entry body exceeds the 100 MB limit.Split the document into smaller entries.
idempotency_key_reuse409invalid_request_errorThe idempotency-key was reused with a different request body.Use a fresh key for a different body, or resend the identical body to replay.
entry_read_only409invalid_request_errorThe entry comes from a reference mount; it cannot be edited or deleted in place.Edit it in the source brain, or unmount the pack.
preview_stale409invalid_request_errorThe affected set changed between a forget dry_run and executing its preview_token.Re-run the dry_run to get a fresh preview_token.
invalid_preview_token400invalid_request_errorA forget preview_token is malformed or targets a different set.Use the token returned by the matching dry_run.
brain_unfunded402invalid_request_errorThe account is unfunded and the brain has on_unfunded:reject (surfaced only on observe?wait=true).Fund the account, or set on_unfunded to fallback/recall_only.
brain_has_mounts409invalid_request_errorReindex was called on a brain with live pack mounts (they share the embedding space).Unmount all packs, then reindex.
reindex_in_progress409invalid_request_errorA reindex is already running for this brain.Poll the existing job; only one runs at a time.
reindex_not_rollbackable409invalid_request_errorRollback was called on a job that is not completed.Only a completed reindex can be rolled back.
rollback_window_expired409invalid_request_errorThe 7-day reindex rollback window has passed.The prior embedding space has been garbage-collected; reindex again if needed.

Worked example: handling the common failures

A real client catches the credential, validation, rate-limit and wallet families separately, captures the request id and retries only the recoverable ones. This is the minimum production-grade handler.

import time
from openai import OpenAI, APIStatusError

client = OpenAI(
    base_url="https://api.fightclub.pro/v1",
    api_key="ko_REDACTED",
)

def chat_with_retry(messages, attempts=4):
    for i in range(attempts):
        try:
            return client.chat.completions.create(
                model="fc:openai/gpt-4o-mini",
                messages=messages,
                extra_headers={"FC-Customer": "cus_42"},
            )
        except APIStatusError as e:
            req_id = e.response.headers.get("x-request-id")
            err = (e.body or {}).get("error", {})
            code = err.get("code")

            if e.status_code in (401, 403):
                raise RuntimeError(f"auth/permission: {code} (req {req_id})")
            if e.status_code == 402:
                # wallet_empty / customer_wallet_empty / customer_budget_exceeded
                raise RuntimeError(f"out of funds: {code} (req {req_id})")
            if e.status_code == 429:
                wait = int(e.response.headers.get("retry-after", "1"))
                time.sleep(wait)
                continue
            if 500 <= e.status_code < 600:
                time.sleep(2 ** i)  # backoff on api_error
                continue
            # 400/404/409/422 are your bug: do not retry
            raise RuntimeError(f"bad request: {code} param={err.get('param')} (req {req_id})")
    raise RuntimeError("exhausted retries")

Retry policy by status

StatusRetry?Rule
400 / 404 / 409 / 413 / 422NoYour request is wrong or the resource is in a forbidding state. Fix it; retrying repeats the same failure.
401 / 403NoFix the credential or scope first.
402NoTop up the wallet or raise the budget. Watch X-Ringside-Wallet-Balance to pre-empt it.
429YesSleep for the integer Retry-After (or retry_after_seconds), then retry. Add jitter under sustained load.
500 / 503YesExponential backoff. Use idempotency-key so a retry of a write does not double-charge.
501NoThe endpoint is not live yet.
Idempotent retries

For any state-changing call, set the lowercase idempotency-key header. The replay window is 24 h and the key is scoped to (developer, customer, key), so a retried topup, batch create or run kicks back the original result instead of a duplicate. One exception: a model: [...] cascade plus an idempotency-key is rejected with fallback_with_idempotency_unsupported.