Esc to close · ⌘K / Ctrl-K opens search anywhere
Every error, on every endpoint, uses one JSON envelope:
{
"error": {
"message": "Human-readable explanation",
"type": "rate_limit_error",
"code": "daily_limit_reached"
}
}Branch on code (stable, machine-oriented); show message to humans. 429 responses carry a retry-after header (seconds).
Absence of retry-after is meaningful. We send it only when waiting actually helps. A 503 provider_unavailable_billing has noretry-after because no amount of waiting fixes it — it clears when we top the provider account up. Treat a retryable failure and an unrecoverable one differently rather than backing off blindly into both.
One caveat worth knowing: no_route is emitted at three statuses (400, 404, 422). The code means the same thing each time — nothing was dialed — and the status tells you why the pool was empty. Branch on the status if you need to distinguish them; see the table below.
Everything on this page below describes an application error: the gateway ran, decided, and answered with the JSON envelope above. Occasionally you will instead get a5xx with an empty body. That did not come from the gateway. It came from the edge proxy in front of it, and it means your request was never processed.
The distinction is not cosmetic — branching on error.code gives youundefined, so code that switches on our codes falls through to its default branch and usually reports something misleading.
| Application error | Infrastructure error | |
|---|---|---|
| Body | JSON envelope with error.code | Empty (content-length: 0) |
server header | — | Caddy |
| Reached the gateway? | Yes — it chose this response | No |
| Retry? | Per the table below; absence of retry-after means waiting won't help | Yes, with backoff. No retry-after is sent, but here that means "we can't estimate", not "don't retry" |
Detect it on the body, not the status. 502, 503 and 504 all appear as documented application codes too, so the status alone cannot tell you which kind you have. An empty body with no parseable error object is the reliable signal:
if (!res.ok) {
const text = await res.text();
if (!text) {
// Edge/infrastructure: never reached the gateway. Retry with backoff.
// Nothing was routed, so nothing was metered.
throw new RetryableError(`gateway unreachable (HTTP ${res.status})`);
}
const { error } = JSON.parse(text); // application error: branch on error.code
}Where to look: status.bharatrouter.com, which is probed from outside our infrastructure and so keeps working when the gateway does not. Note that GET /health is not useful for this class of failure: if the edge cannot reach the gateway, it cannot serve /health either, so you will get the same empty 503 from the endpoint you are using to diagnose the empty 503.
| HTTP | Code | When | What to do |
|---|---|---|---|
| 401 | invalid_api_key | Missing, malformed, revoked, or expired key. | Check the Authorization: Bearer br-… header; mint a new key in the console. |
| 404 | model_not_found | Model id isn't in the catalog and isn't a BYOK-discovered model. This is the status on chat, embeddings and /v1/models. | List valid ids at GET /v1/models; for BYOK models use the provider/model-id form. |
| 400 | model_not_found | The same condition on the multimodal endpoints — images, audio and documents — where an unknown id is treated as a bad request parameter rather than a missing resource. Same code, same meaning; only the status differs by endpoint family. | As above. If you branch on status rather than code, handle both. |
| 400 | no_route | Pre-flight. The model exists but no eligible route could be resolved, so nothing was dialed — a pinned provider that doesn't serve the model, a BYOK-only model with no saved key, or a fallback chain whose every step is unresolvable. | Relax the constraint, save a provider key, or pick a model with a platform route from the catalog. |
| 404 | no_route | Pre-flight, via a preset. An auto:* preset or route: "best" resolved to no reachable model for this request. 404 rather than 400 because the preset itself is valid — it just has nothing to point at right now. | Add a provider (BYOK) key, relax the preset, or request a concrete model id — see GET /v1/models. |
| 422 | no_route | Pre-flight, residency. A hard constraint emptied the candidate pool — typically data_policy: india_only on a model or Sangam panel with no India-resident member. The request is well-formed but cannot be satisfied, so it is refused rather than silently served offshore. | Choose an India-resident model or panel (e.g. bharatrouter/auto), or drop the residency pin for this call. |
| 403 | model_gated | Model requires a non-zero data-retention window that's off by default (BharatRouter is zero-retention by default). Affects models like claude-fable-5, which needs 30-day retention. | An owner/admin enables it once at Console → Organization (PUT /me/org/data-retention), or an agent on a management key calls the set_data_retention MCP tool, then retry. |
| 402 | insufficient_credits | Standard key, balance is zero or negative. | Top up. Not retryable until you do. |
| 429 | rate_limit_exceeded | Per-minute limit hit (key's rpm_limit or the 120/min gateway cap). | Back off per retry-after; raise the key's limit if self-imposed. |
| 429 | daily_limit_reached | Key's daily request cap spent. | Resets at midnight IST. Raise the cap, or upgrade off a trial key. |
| 429 | budget_exceeded | The key's or its workspace's monthly ₹ budget is spent. | Raise the budget on the dashboard, or wait for the new month (IST). |
| 502 | all_routes_failed | Runtime. Routes existed and were dialed, but every one failed transiently; the message includes the last upstream error. | Retry with backoff — circuits recover in ~30 s. Check status for incidents. |
| 503 | provider_unavailable_billing | Runtime, our side. The route was dialed and the provider refused on billing grounds — the account behind it is unfunded or suspended. Nothing to do with your key or your balance. | Not retryable, and deliberately sent without a retry-after: only a top-up on our side clears it. Pick another model; GET /health names the affected provider. |
| HTTP | Code | When |
|---|---|---|
| 401 | no_session | Calling a /me/* endpoint without being signed in. |
| 400 | duplicate_key_name | An active key with that name already exists in your org. |
| 400 | trial_ceiling | Trying to raise a trial key past 60 req/min or 200 req/day. |
| 503 | provider_not_configured | That OAuth sign-in method isn't configured on this deployment. |
| HTTP | Code | When |
|---|---|---|
| 400 | address_required | Creating an order before adding a billing address. |
| 400 | bad_address | Billing address failed validation (the message names the field). |
| 400 | promo_invalid | Promo code doesn't exist, is expired, or is exhausted. |
| 400 | promo_redeemed | Your org already redeemed this code. |
| 503 | billing_disabled | Billing isn't configured on this deployment. |
| HTTP | Code | When |
|---|---|---|
| 400 | bad_key | Submitted key doesn't look valid for that provider. |
| 404 | unknown_provider | No such BYOK provider id. |
| 503 | byok_disabled | BYOK isn't configured on this deployment. |
The management endpoints share a small set of validation codes:
| HTTP | Code | When |
|---|---|---|
| 403 | forbidden | Action needs a role you don't have (most writes are owner/admin-only). |
| 401 | key_disabled_inactive | The key was soft-disabled after inactivity (org policy, default 90 days idle + 14-day notice; never deleted). An owner/admin re-enables it in the console or with PATCH /me/keys/:id {"enabled": true}; mark idle-by-design keys {"lifecycle_pin": "keep"} to exempt them. Threshold: PUT /me/org/key-inactivity. |
| 404 | not_found | No such collection, endpoint, chain, member, or invitation in your org. |
| 400 | bad_steps / bad_model | A chain or collection has invalid steps, or targets a model that isn't a catalog id. |
| 400 | bad_name / bad_readme | Name or README fails validation (length or characters). |
| 400 | cap / team_cap / member_cap | A per-org limit is reached (collections, team orgs, or members). |
| 400 | bad_request / unavailable | BYOE config is invalid (e.g. SSRF-blocked URL) or the feature isn't configured. |
| 400 | bad_email / bad_role / already_member / already_invited / last_owner / personal_org | Membership errors — see Teams (you can't demote the last owner, or add members to a personal org). |
| 400 | duplicate_workspace | A workspace with that name already exists in the org. |
A 403 with Cloudflare "error 1010" (an HTML body, not our JSON error shape) means the request was blocked at the edge WAF before reaching the gateway — it is triggered by bot-fighting rules on some default HTTP-client User-Agent strings (e.g. bare python-requests/python-urllib). It is not a rate limit and not an auth failure, though it is often misread as both. Fix: send a realUser-Agent header identifying your application (e.g. my-app/1.0) — the official OpenAI SDKs already do and are unaffected. If a legitimate integration still gets 1010, tell us and we'll allow-list its agent string.
With the OpenAI SDKs, BharatRouter errors surface as the SDK's standard exceptions — the envelope rides inside. A retry policy that covers everything above:
# Python
import time, openai
def chat(client, **kw):
for attempt in range(4):
try:
return client.chat.completions.create(**kw)
except openai.RateLimitError as e: # 429: rate/daily/budget
code = (getattr(e, "body", None) or {}).get("error", {}).get("code")
if code in ("daily_limit_reached", "budget_exceeded"):
raise # waiting seconds won't help
time.sleep(2 ** attempt)
except openai.APIStatusError as e:
if e.status_code == 502: # all_routes_failed
time.sleep(2 ** attempt) # circuits recover in ~30s
else:
raise # 400/401/402: fix, don't retry
raise RuntimeError("retries exhausted")// Node
try {
await client.chat.completions.create({ model: "gemma-4-e4b-it", messages });
} catch (err) {
const code = err?.error?.code ?? err?.code;
if (code === "insufficient_credits") notifyOwnerToTopUp();
else if (err.status === 429) scheduleRetry(err.headers?.["retry-after"]);
else if (err.status === 502) scheduleRetry(30); // all_routes_failed
else throw err;
}Rule of thumb: retry 429 (except daily_limit_reached /budget_exceeded) and 502 with backoff; never blind-retry 400/401/402 — they need a fix, not patience.