Errors
Error shapes, status codes, and what to retry.
All AOCore endpoints return errors in a single, consistent shape: the APIError
schema. The same shape appears in the
API reference on every non-2xx response. This
page documents:
- The wire shape.
- The HTTP status codes and what each one means.
- The retry policy.
- The auth-error variants (parity-locked to prevent role enumeration).
The APIError shape
Every error response is JSON, wrapped in an "error" object:
{
"error": {
"message": "Human-readable description of what went wrong.",
"type": "machine-readable error class",
"code": "more-specific code within the type",
"param": "(optional) request parameter at fault, when applicable"
}
}
The wrapper preserves OpenAI-SDK compatibility (openai.APIError parses this
shape directly). Some fields are optional:
| Field | Required | Notes |
|---|---|---|
message | Yes | UI-safe English description; never includes internal stack traces |
type | Yes | Stable enum, suitable for switch/match |
code | Yes | More specific identifier; stable across releases |
param | No | The request parameter that caused the error (e.g., "model", "messages.0.content") |
Status codes & error types
| Status | type | When |
|---|---|---|
400 | validation_error | Malformed request body, missing required fields, invalid enum value |
400 | invalid_request_error | Logically invalid request — wrong shape for the model, unsupported flag, malformed conversation |
401 | authentication_error | Missing / invalid / expired credentials (API key OR session cookie) |
403 | permission_error | Authenticated but operation is forbidden — e.g. trying to mint a key for another developer |
404 | not_found_error | Resource doesn’t exist OR caller’s allowlist doesn’t include it (see the models guide) |
409 | conflict_error | Email already registered; key name already in use; invitation already consumed |
422 | unprocessable_entity | Guardrails rejected the prompt or response (PII / jailbreak / regex / content moderation) |
429 | rate_limit_error | RPM or TPM exceeded — retry after X-AOSentry-RateLimit-Reset |
429 | rate_limit_error (code budget_exceeded) | Per-key dollar budget exceeded — NOT retryable on the same key |
500 | api_error | Internal gateway error (DB unavailable, upstream provider hard failure, etc.) |
502 | bad_gateway | Upstream provider returned a non-recoverable error |
503 | service_unavailable | Gateway temporarily overloaded |
504 | timeout_error | Upstream provider didn’t respond within the gateway’s timeout |
The code field is more specific than type. For example, the validation_error
type carries codes like missing_field, invalid_format, value_too_long, etc.
The list of codes is part of the OpenAPI spec — see
the API reference under the relevant endpoint’s response
schema.
Quota headers on every response
The six X-AOSentry-* quota headers (see the rate-limits guide)
are stamped on error responses too, not just 200s. So when you get a 429,
the same response carries X-AOSentry-RateLimit-Reset — you know exactly when
to retry without a second probe call. The 400/401/404/5xx responses also
carry the headers when the request reaches the rate-limiter (i.e., after API-key
authentication succeeds).
Retry policy
The recommended client policy is:
import time
from openai import OpenAI
def call_with_retry(client, **kwargs):
delays = [1, 2, 4] # exponential backoff for 5xx
for attempt in range(len(delays) + 1):
try:
return client.chat.completions.create(**kwargs)
except openai.APIStatusError as e:
if e.status_code == 429:
if e.response.json()["error"].get("code") == "budget_exceeded":
raise # No amount of waiting fixes this
reset = int(e.response.headers.get("X-AOSentry-RateLimit-Reset", "0"))
time.sleep(max(0.0, reset - time.time()))
continue
elif 500 <= e.status_code < 600:
if attempt < len(delays):
time.sleep(delays[attempt])
continue
raise
else:
raise # 4xx — don't retry
return None
Key points:
| Status | Retry? | Strategy |
|---|---|---|
400, 404, 422 | No | Caller error; fix the request |
401, 403 | No | Credential or permission issue; surface to user |
409 | No | Idempotency conflict; usually not retryable |
429 (rate_limit_exceeded) | Yes | Wait until X-AOSentry-RateLimit-Reset, then retry |
429 (budget_exceeded) | No | Budget bumps require admin; alert |
5xx | Yes | Exponential backoff (1s, 2s, 4s, …); cap at 3 retries |
/v1/chat/completions is not idempotent by default — each retry creates a
new completion (and bills you for it). For mutating management endpoints
(POST /developer/v1/keys), an Idempotency-Key header is honored: passing
the same key twice within the cache window returns the same response without
double-charging. (See the API reference for which
endpoints support it.)
Auth error parity
The four authentication-failure paths return byte-identical response bodies to prevent role enumeration:
- No cookie / no API key.
- Cookie is for a user whose role doesn’t match the endpoint.
- Session is expired.
- Session is revoked.
{
"error": {
"message": "Invalid or expired developer session",
"type": "authentication_error",
"code": "invalid_session"
}
}
(/v1/* returns the same shape with a different message mentioning API keys.)
This is locked by TestDeveloperSessionMiddleware_ErrorBodyParity and
TestAdminMiddleware_RejectsDeveloperRole. An attacker can not distinguish
“this email doesn’t exist” from “this email is an admin, not a developer” by
comparing 401 bodies. See the authentication guide for context.
The 404-on-unauthorized-model property
Calling GET /v1/models/{model} for a model not in your allowlist returns
404, not 403. This is intentional and locked by spec. See
the models guide for the full
description (including the verbatim spec language: “Returned both when the
model genuinely does not exist AND when the model exists but is not in the
caller’s allowlist”).
If you receive a 404 on a model lookup, do not assume the model doesn’t exist
on the gateway — only that it’s not in your specific key’s effective allowlist.
Guardrails errors (422)
When a guardrail (PII / jailbreak / content moderation / secrets / regex) blocks
a request or response, the error type is unprocessable_entity with a code
identifying which guardrail fired:
{
"error": {
"message": "Request blocked by content moderation: detected categories: violence",
"type": "unprocessable_entity",
"code": "guardrail_blocked",
"param": "content_moderation"
}
}
The param field carries the guardrail name. If a guardrail is configured in
mode: log (rather than mode: block), the request is not rejected — it
flows through with an audit-log entry instead. Your administrator configures
guardrail modes per gateway.
Inspecting error payloads from the OpenAI SDK
from openai import OpenAI, APIStatusError
import os
client = OpenAI(
base_url="https://gateway.core.aocyber.ai/v1",
api_key=os.environ["AOSENTRY_KEY"],
)
try:
client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "..."}],
)
except APIStatusError as e:
err = e.response.json()["error"]
print(f"status={e.status_code} type={err['type']} code={err['code']}")
print(f"message={err['message']}")
if "param" in err:
print(f"param={err['param']}")
The OpenAI SDK exposes e.response.headers so you can correlate with the
X-AOSentry-* quota headers in the same handler.
See examples/python-openai-sdk/streaming.py for an error-handling example in the SSE streaming path (where errors arrive as SSE-shaped events rather than HTTP error responses).
Related
- the authentication guide — auth-error shapes and parity
- the rate-limits guide —
429handling +X-AOSentry-RateLimit-Reset - the models guide —
404vs403and the anti-enumeration property - the Quickstart — your first call (and what could go wrong)
- API reference — the full
APIErrorschema and per-endpoint responses