The OpenDunes error format, what each status means, and how to handle failures.
When a request fails before the model starts answering, the API returns a non-2xx HTTP status with a JSON body. The body is in the shape of the library the route speaks: the OpenAI shape on every route but two, the Anthropic shape on /v1/messages and the Responses shape on /v1/responses. The status, the message and the one-word error_type are the same in all three.
{
"error": {
"code": 402,
"message": "This API key has reached its monthly limit of 2,000.00 DA.",
"metadata": {
"limit_source": "key",
"error_type": "payment_required"
}
}
}| Field | Type | Always present | Description |
|---|---|---|---|
error.code | integer | Yes | The HTTP status, repeated |
error.message | string | Yes | A human-readable explanation |
error.metadata | object | Yes | The detail, when there is one |
error.metadata.error_type | string | Yes | The cause in one word — see Error types |
metadata carries whatever detail applies: the limit that stopped a 402 (limit_source), the fields that failed validation on a 400 (errors), the kinds of personal data a filter found on a 403 (types), the provider's own code on a refusal from the model (provider_code).
const res = await fetch("https://opendunes.com/v1/chat/completions", { /* … */ });
if (!res.ok) {
const { error } = await res.json();
console.error(error.code, error.metadata.error_type, error.message);
}An error on /v1/messages is the object the Anthropic libraries read:
{
"type": "error",
"error": {
"type": "billing_error",
"message": "Insufficient balance for this request.",
"error_type": "payment_required"
},
"request_id": null,
"metadata": {
"limit_source": "wallet"
}
}| Field | Type | Always present | Description |
|---|---|---|---|
type | string | Yes | Always error |
error.type | string | Yes | The Anthropic word for the status — see the table below |
error.message | string | Yes | A human-readable explanation |
error.error_type | string | Yes | The cause in one word — see Error types |
request_id | null | Yes | null on an error the API raises itself |
metadata | object | No | The same detail as the OpenAI shape's error.metadata, when there is one |
| HTTP | error.type |
|---|---|
| 400 · 413 · 422 | invalid_request_error |
| 401 | authentication_error |
| 402 | billing_error |
| 403 | permission_error |
| 404 | not_found_error |
| 408 · 504 | timeout_error |
| 429 | rate_limit_error |
| 503 | overloaded_error |
| 500 · 502 | api_error |
An error on /v1/responses is a failed response, the object the OpenAI library's responses.create reads:
{
"id": "resp_a2d1a1edd30c48a4823171d3aa1e1b78",
"status": "failed",
"error": {
"code": "server_error",
"message": "Invalid API key."
},
"error_type": "authentication"
}| Field | Type | Always present | Description |
|---|---|---|---|
id | string | Yes | resp_ followed by the request's X-Request-Id without its dashes |
status | string | Yes | Always failed |
error.code | string | Yes | rate_limit_exceeded on a 429, server_error on every other status |
error.message | string | Yes | A human-readable explanation |
error_type | string | Yes | The cause in one word — see Error types |
metadata | object | No | The same detail as the OpenAI shape's error.metadata, when there is one |
In both shapes the HTTP status is the one in the tables below. Read the status from the response, not from the body.
The request:
| HTTP | When |
|---|---|
| 400 | Malformed, a required field is missing, or the prompt is longer than the model takes. metadata.errors lists the fields |
| 404 | The model, file or job does not exist — a retired model, or a name ending in :free, included |
| 413 | The body, or an uploaded file, is too large. One file may be up to 100 MB |
| 422 | Well formed, and impossible to carry out — for example a file type Files does not take, or a file_id that is not yours |
You, the caller:
| HTTP | When |
|---|---|
| 401 | The key is missing, wrong, disabled or expired — or a management key was sent to a model route, or an API key to a management route |
| 402 | Your wallet is at zero or below, the key's cap is spent, or a guardrail's budget is spent. metadata.limit_source says which |
| 403 | A guardrail blocked the request, a filter flagged it, the key is not allowed that model or that IP, or the model refused as an error |
The model:
| HTTP | When |
|---|---|
| 408 · 504 | The provider did not answer in time |
| 429 | The provider is limiting its rate — see Retry-After |
| 500 | An unexpected error. The message is generic and the provider's code is withheld |
| 502 | The model is down, or answered with nothing usable |
| 503 | No provider meets the routing the request asked for |
A 429 and a 503 carry a Retry-After header: the number of seconds to wait before retrying. A 429 also carries the provider's X-RateLimit-* headers when it sent them. No successful answer carries either.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{"error":{"code":429,"message":"Rate limit exceeded.","metadata":{"error_type":"rate_limit_exceeded"}}}The OpenAI and Anthropic libraries already honour Retry-After on the statuses they retry. With fetch, read the header and wait before retrying. A 402 is never a wait-and-retry case.
A 402 names the limit that stopped it in metadata.limit_source:
limit_source | Means | What to do |
|---|---|---|
wallet | Your balance is zero or below | Deposit DA from your dashboard with a CIB or Edahabia card, then retry the same request |
key | This key has spent its cap | Raise or clear the cap on the key, or use another key. Your other keys keep working |
guardrail | A guardrail on this key has spent its budget | Wait for the budget to reset, or change the guardrail |
A request admitted while your balance is above zero settles in full, even past zero or past a cap.
A request a guardrail blocks answers 403 before it reaches a model, and nothing is charged. When the sensitive-information filter blocks it, metadata.types lists what it found:
{
"error": {
"code": 403,
"message": "Personal information detected in prompt. Please remove PII before sending.",
"metadata": {
"types": ["email"],
"error_type": "permission_denied"
}
}
}When the provider refuses or fails, you get its status and its message, with its own error code in metadata.provider_code where it gave one. A 500 is the exception: its message is always generic and nothing of the cause is passed on.
An error before the stream opens — a bad key, an empty wallet, an unknown model, a provider that fails before its first word — arrives as an ordinary error response, with its status and the route's shape above.
Once the answer has begun, its 200 is already sent. An error after that point arrives inside the answer:
Streamed, the last event carries error and finish_reason: "error", then [DONE]:
data: {"object":"chat.completion.chunk","error":{"code":502,"message":"The model is down, or answered with nothing usable.","metadata":{"error_type":"provider_unavailable"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}
data: [DONE]Not streamed, the body carries error and no choices.
See Streaming for the event format.
metadata.error_type names the cause in one word, the same word on every route:
error_type | Usual status |
|---|---|
invalid_request | 400 |
authentication | 401 |
payment_required | 402 |
permission_denied | 403 |
not_found | 404 |
timeout | 408, 504 |
payload_too_large | 413 |
unprocessable | 422 |
rate_limit_exceeded | 429 |
server | 500 |
provider_unavailable | 502 |
provider_overloaded | 503 |
A provider that names a more precise type keeps its own word.
Every response — success or error — carries an X-Request-Id header:
X-Request-Id: a2d19592-a8d5-49f6-94be-4ed27498a8aaInclude it when you contact support. A model answer also carries X-Generation-Id, the handle of its receipt.