Error responses
Every failure returns the same JSON envelope with a customer-safe message. Internal detail — database errors, model-provider identity, stack traces — never appears in a response, with debug mode on or off.
The envelope
Four fields, always these four, on every failure from every endpoint.
param names the offending field when one can be identified, and
is null otherwise.
{
"error": {
"message": "The model 'gpt-9' does not exist or is not available.",
"type": "invalid_request_error",
"param": "model",
"code": "model_not_found"
}
}
type and code come from closed sets that only change
with a version. Messages are written for humans and may be reworded at any
time. Client logic that matches on message text will break.
Every error, and what to do about it
This table is generated from the same definition the API validates against, so a code listed here is a code the service can actually return.
| HTTP | code | When | What to do |
|---|---|---|---|
| 400 | invalid_request_errorinvalid_request_error |
The request body or a query parameter is invalid. `param` names the field. | Fix the named field. Retrying unchanged will fail identically. |
| 401 | authentication_errorauthentication_error |
No credential was presented, or the wrong kind was. | Send `Authorization: Bearer <key>`. Session endpoints refuse an `ak-` token by design. |
| 401 | invalid_api_keyauthentication_error |
The key is not recognised. | Check the key, or create a new one. |
| 401 | expired_api_keyauthentication_error |
The key passed its expiry. | Rotate the key, or create a new one. |
| 403 | beta_access_requiredaccess_denied |
Authenticated, but this account is not in the invite-only beta. | Retrying cannot help and the credential is not the problem. Request access; nothing was charged. |
| 402 | insufficient_fundsinsufficient_funds |
The balance cannot cover this request's maximum cost. | Add funds. Nothing was reserved or charged. |
| 404 | model_not_foundinvalid_request_error |
The model does not exist, or is not available to this key. | Call `GET /v1/models`. Unknown and not-permitted answer identically on purpose. |
| 404 | api_key_not_foundinvalid_request_error |
No such key on this account. | Check the id. A key belonging to someone else answers the same way. |
| 409 | idempotency_conflictinvalid_request_error |
This `Idempotency-Key` was used with a different request body. | Use a new key, or send the identical body. |
| 409 | idempotency_in_progressinvalid_request_error |
The original request with this key has not finished. | Wait and retry with the same key. |
| 409 | api_key_revokedinvalid_request_error |
The key is revoked and cannot be rotated. | Create a new key. |
| 429 | rate_limit_errorrate_limit_error |
Too many requests, or too many at once, for this key. | Back off. Honour `Retry-After` when present. |
| 429 | spending_limit_exceededrate_limit_error |
The key's own spending limit is reached. | Raise the key's limit, or use another key. |
| 500 | internal_errorservice_unavailable |
Something failed on our side. | Retry with backoff. Quote `X-Astrodyne-Request-Id`. |
| 500 | billing_errorbilling_error |
The request completed but the charge was not recorded. | Do not retry. Quote the request id — this is flagged for review automatically. |
| 503 | service_unavailableservice_unavailable |
The service is not accepting this request right now. | Retry with backoff. |
| 504 | upstream_timeoutservice_unavailable |
The model did not respond in time. | Retry. A shorter prompt or a lower `max_tokens` may help. |
| 413 | request_too_largeinvalid_request_error |
The request body exceeds the maximum size. | Send less. Split the work across requests. |
| 403 | model_not_allowedinvalid_request_error |
The key's policy forbids this model. | Use a permitted model, or change the key's allowlist. |
Which failures are safe to retry
- Retry with backoff:
429,503,504, and500 internal_error. HonourRetry-Afterwhen it is present. - Do not retry:
400,401,403,404, and409 idempotency_conflict. Nothing about the next attempt will be different. - Never retry:
500 billing_error. The request completed and the charge was not recorded; it is already flagged for review, and a retry would run the work a second time. 409 idempotency_in_progressis the one conflict worth waiting on — the original request has not finished yet.
To make a retry safe on a request that may already have run, send an
Idempotency-Key.
Handling every case
A complete client that handles each status above, with the retry rules applied.
import os
import time
import httpx
headers = {"Authorization": f"Bearer {os.environ['ASTRODYNE_API_KEY']}"}
body = {"model": "YOUR_MODEL_ID",
"messages": [{"role": "user", "content": "Hello"}]}
def complete(http, attempt=0):
response = http.post("/v1/chat/completions", headers=headers, json=body)
# Log this on every outcome, success or failure. It is what support needs.
request_id = response.headers.get("X-Astrodyne-Request-Id")
if response.status_code == 200:
return response.json()
# Every failure has this exact shape. Branch on code, never on message.
error = response.json()["error"]
code = error["code"]
if response.status_code == 401:
raise SystemExit(f"Bad credential ({code}). Check ASTRODYNE_API_KEY.")
if response.status_code == 403:
# Authenticated, but this account may not use the API. Retrying
# cannot help and nothing was charged.
raise SystemExit(f"Access denied ({code}).")
if response.status_code == 404:
# The model is unknown OR not permitted for this key — deliberately
# indistinguishable. Call /v1/models to see what you may use.
raise SystemExit(f"Not found ({code}): {error['param']}")
if response.status_code == 409:
# An Idempotency-Key collision. idempotency_in_progress is worth
# waiting on; idempotency_conflict means the body changed.
if code == "idempotency_in_progress" and attempt < 5:
time.sleep(2 ** attempt)
return complete(http, attempt + 1)
raise SystemExit(f"Conflict ({code}).")
if response.status_code == 429:
# Honour Retry-After when it is there; back off when it is not.
wait = float(response.headers.get("Retry-After", 2 ** attempt))
if attempt < 5:
time.sleep(wait)
return complete(http, attempt + 1)
raise SystemExit("Rate limited; giving up.")
if response.status_code >= 500:
# billing_error is the one 5xx you must NOT retry: the request
# completed and the charge is already flagged for review.
if code == "billing_error":
raise SystemExit(f"Billing error; quote request id {request_id}.")
if attempt < 5:
time.sleep(2 ** attempt)
return complete(http, attempt + 1)
raise SystemExit(f"Unhandled {response.status_code} {code} ({request_id})")
with httpx.Client(base_url="https://api.astrodyne.ai", timeout=60) as http:
print(complete(http)["choices"][0]["message"]["content"])
const headers = {
Authorization: `Bearer ${process.env.ASTRODYNE_API_KEY}`,
"Content-Type": "application/json",
};
const body = JSON.stringify({
model: "YOUR_MODEL_ID",
messages: [{ role: "user", content: "Hello" }],
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function complete(attempt = 0) {
const response = await fetch("https://api.astrodyne.ai/v1/chat/completions", {
method: "POST", headers, body,
});
// Log this on every outcome. It is what support needs.
const requestId = response.headers.get("X-Astrodyne-Request-Id");
if (response.ok) return response.json();
// Every failure has this exact shape. Branch on code, never on message.
const { error } = await response.json();
if (response.status === 401) throw new Error(`Bad credential (${error.code})`);
if (response.status === 403) throw new Error(`Access denied (${error.code})`);
if (response.status === 404) throw new Error(`Not found (${error.code})`);
if (response.status === 409) {
if (error.code === "idempotency_in_progress" && attempt < 5) {
await sleep(2 ** attempt * 1000);
return complete(attempt + 1);
}
throw new Error(`Conflict (${error.code})`);
}
if (response.status === 429 && attempt < 5) {
const wait = Number(response.headers.get("Retry-After") ?? 2 ** attempt);
await sleep(wait * 1000);
return complete(attempt + 1);
}
if (response.status >= 500) {
// billing_error is the one 5xx you must NOT retry.
if (error.code === "billing_error") {
throw new Error(`Billing error; quote request id ${requestId}`);
}
if (attempt < 5) {
await sleep(2 ** attempt * 1000);
return complete(attempt + 1);
}
}
throw new Error(`Unhandled ${response.status} ${error.code} (${requestId})`);
}
const result = await complete();
console.log(result.choices[0].message.content);
Failures during a stream
Once streaming has begun the HTTP status can no longer change, so a failure
arrives as a single sanitized event in the stream and the stream then closes
without data: [DONE]:
data: {"object": "error", "error": {"message": "...", "type": "service_unavailable", "param": null, "code": "service_unavailable"}}
Treat a stream that ends without [DONE] as failed even if no
error event arrived. You are charged only for output actually delivered to
you — see Streaming.
Always capture the request id
X-Astrodyne-Request-Idis on responses to successes and failures alike. Log it with your own diagnostics.- It is the one identifier support can act on. No other detail about a failure is recoverable after the fact.