The shape of an error
Every error comes back the same way, so you can handle them with one branch rather than a special case per endpoint.
{
"error": {
"code": "INVALID_API_KEY",
"message": "API key not found or has been revoked."
}
}Two cases do not return this envelope: (a) request-body parsing failures, malformed JSON, a missing required field, or a missing Content-Type: application/json header, are rejected by the HTTP layer before the handler runs and return a plain-text body with a 400, 415, or 422 status (a missing prompt is a 422 here, not EMPTY_PROMPT); (b) an unrecognised /api/v1/* path returns 404 with a welcome JSON object rather than the error envelope. Always guard response.json() and check for the error key before indexing it.
Every code
The full list. Most integrations only ever meet a handful of these, and the ones worth handling deliberately are 401, 429 and 402.
| HTTP | Code | Description |
|---|---|---|
| 401 | MISSING_API_KEY | Missing bearer header. |
| 401 | INVALID_API_KEY | Invalid, expired, or revoked key. |
| 400 | API_KEY_ERROR | Other API-key validation failure. |
| 403 | INSUFFICIENT_SCOPE | Key scope does not allow the route. |
| 403 | INSUFFICIENT_TIER | Subscription tier too low. |
| 429 | RATE_LIMITED | Minute request envelope or per-day rung ceiling exceeded (the two are indistinguishable by code). |
| 503 | BUDGET_UNAVAILABLE | Budget enforcement could not be verified; the hard cap fails closed. Retry. |
| 400 | EMPTY_PROMPT | Prompt missing. |
| 400 | PROMPT_TOO_LARGE | Prompt exceeds 100,000 bytes. |
| 400 | INVALID_MODEL | Unknown model identifier. |
| 400 | MODEL_UNAVAILABLE | Known model ID, but not currently available. |
| 400 | EMPTY_BATCH | Batch request contained no items. |
| 400 | BATCH_TOO_LARGE | Batch exceeded the 100-request limit. |
| 400 | INVALID_REQUEST | Invalid request contract, includes a max_output_rungs of 0 or above the selected model's output limit, and malformed requests rejected by the inference gateway. |
| 402 | BUDGET_EXCEEDED | Project hard cap would be exceeded by the request. |
| 404 | NOT_FOUND | Requested upstream object was not found. |
| 404 | SOURCES_NOT_FOUND | No live sources retained for that request ID. |
| 413 | PAYLOAD_TOO_LARGE | Upstream payload exceeded the accepted size limit. |
| 422 | SAFETY_BLOCKED | Gateway safety pre-screen blocked the request. |
| 429 | INFERENCE_RATE_LIMITED | Upstream inference service throttled the request. |
| 503 | EVA_UNAVAILABLE | Inference service unavailable. |
| 401 | INFERENCE_UNAUTHORIZED | Gateway credentials were rejected upstream. |
| 503 | INFERENCE_BUSY | Inference backend reported temporary saturation. |
| 500 | DB_ERROR | Usage or billing query failed. |
| 500 | SERVER_MISCONFIGURED | API key verification is not configured correctly. |
| 502 | INFERENCE_ERROR | Gateway or upstream dispatch failure on non-streaming requests. Also emitted in-band as a response.error SSE event on streaming requests, where the HTTP status is 200. |
| 503 | DB_UNAVAILABLE | Required database services are unavailable. |
What to retry
The short version: retry when the platform is having a problem, and do not retry when your request is the problem. Retrying a rejected payload just spends your rate limit arriving at the same answer.
429 Rate Limited
When request limits are exceeded, the API returns retry-after (seconds, always at least 1, the exact value depends on which limiter tripped), x-ratelimit-reset (same value), x-ratelimit-limit, and x-ratelimit-remaining. Read retry-after from the response rather than assuming a fixed window.
Rate-limit headers are attached to successful (2xx) responses and to 429 responses only. Other 4xx/5xx responses carry none, so do not rely on them for backoff on those paths.
400 and 402 Contract or Budget Errors
Do not retry unchanged requests that fail with validation, model-selection, or budget-cap errors. Fix the payload, switch model, lower max_output_rungs, or adjust the project budget first.
500, 502 and 503 Service Errors
Retry with bounded exponential backoff. If the failure persists, stop retrying and inspect upstream availability, database health, workspace budget state, and current platform status.
Handling this in practice
import requests
import time
def call_eva(prompt, retries=3):
for attempt in range(retries):
response = requests.post(
"https://evaesi.dev/api/v1/responses",
headers={"Authorization": "Bearer esi_dev_YOUR_KEY"},
json={"prompt": prompt, "model": "instant"},
)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
wait = int(response.headers.get("retry-after", 60))
time.sleep(wait)
continue
if response.status_code >= 500:
time.sleep(2 ** attempt)
continue
# Body-parse rejections (400/415/422) return plain text, not the
# error envelope - guard the JSON decode before indexing it.
try:
error = response.json()["error"]["message"]
except (ValueError, KeyError):
error = response.text
raise Exception(f"{response.status_code}: {error}")
raise Exception("Max retries exceeded")