Errors
HTTP status codes from Half Off Dev, error bodies in Anthropic, OpenAI and Gemini format, stream errors, and retry code you can paste.
Half-price tokens are only cheap if your code handles failures well. Half Off Dev keeps this simple: errors use plain HTTP status codes, and the body is written in the format of the API you called, so the Anthropic, OpenAI and Google SDKs raise the same exceptions they would against the vendor. As a rule of thumb, 4xx means change the request (or add credit), while 429 and 5xx are temporary and worth retrying.
Status codes at a glance#
| Status | Name | Typical cause | Retry? |
|---|---|---|---|
400 | Bad request | Body isn't valid JSON. The model rejected a parameter, such as max_tokens above its limit. Unknown x-halfoff-group. The model's content filter refused the request. | No, fix the request |
401 | Unauthenticated | Key missing, mistyped, revoked or expired | No, check the key |
402 | Payment required | Balance at $0, or the key reached its spend limit | Only after topping up or raising the limit |
403 | Forbidden | Model isn't on the key's allow-list | No |
404 | Not found | Unknown model id, a model not served in this API format, an unknown endpoint or the wrong HTTP method | No |
413 | Payload too large | Prompt too long for the model | No, shorten it |
422 | Unprocessable | The model refused the request's content | No |
429 | Too many requests | Key rate or concurrency limit, or the model's capacity is busy | Yes, after retry-after |
500 | Internal error | Unexpected error in Half Off Dev | Yes, with backoff |
502 | Bad gateway | The model failed on every channel Half Off Dev tried | Yes, with backoff |
Handle any other 5xx the way you handle 502. Requests refused before they reach a model are never charged: 400 for invalid JSON or an unknown group, 401, 402, 403, 404 for an unknown model or endpoint, and 429 from a key limit.
Error body formats#
The same 401 looks like this in each of the three formats:
{"type": "error", "error": {"type": "authentication_error", "message": "Invalid or revoked API key"}}Type and status for each code#
| Status | Anthropic error.type | OpenAI error.type and error.code | Gemini error.status |
|---|---|---|---|
400 | invalid_request_error | invalid_request_error | INVALID_ARGUMENT |
401 | authentication_error | authentication_error | UNAUTHENTICATED |
402 | billing_error | insufficient_quota | RESOURCE_EXHAUSTED |
403 | permission_error | permission_denied | PERMISSION_DENIED |
404 | not_found_error, or not_found for an unknown endpoint | not_found_error, or not_found for an unknown endpoint | NOT_FOUND |
413, 422 | invalid_request_error | invalid_request_error | INVALID_ARGUMENT |
429 | rate_limit_error | rate_limit_exceeded | RESOURCE_EXHAUSTED |
500 | api_error | api_error | INTERNAL |
502, 503 | api_error | api_error | UNAVAILABLE |
504 | api_error | api_error | DEADLINE_EXCEEDED |
A Gemini body's error.code is always the HTTP status, and both 402 and 429 map to RESOURCE_EXHAUSTED, so branch on the number, not the string. Even errors raised before the body is read, like an unknown path, use the format that matches the path you called.
Messages you may see#
| Status | message |
|---|---|
400 | Body must be JSON |
400 | Unknown group 'xyz' |
400 | The model provider's content filter refused this request. Rewording it or using another model may help. |
400, 413, 422 | The model's own explanation, for example prompt is too long |
401 | Missing API key, Invalid or revoked API key, This API key has expired |
402 | Insufficient balance. Top up at the dashboard. |
402 | This API key has reached its spend limit. |
403 | This API key is not allowed to use 'gpt-6.1-sol'. |
404 | Model 'gpt-9' is not available. See GET /v1/models. |
404 | Unknown endpoint /v1/embeddings, No route for GET /v1/messages |
429 | This API key has reached its rate or concurrency limit. |
429 | All upstream capacity for this model is busy. Retry shortly. |
502 | The model provider returned an error. Please retry. |
Every error response carries an x-halfoff-request-id header, even when the request never reached a model (bad key, empty balance, unknown model). Include it when you write to support.
Errors inside a stream#
Once a stream is open, the 200 status has already been sent, so a later problem arrives as an event in the stream. Failures before the first token are retried on another channel behind the scenes, which means a stream usually either succeeds or fails with an ordinary HTTP error before it starts.
If the model goes silent for 3 minutes in the middle of a stream, Half Off Dev closes it with an error event in that stream's format:
event: error
data: {"type": "error", "error": {"type": "api_error", "message": "Upstream stopped responding mid-stream"}}Errors that a channel reports partway through a stream are swapped for a generic event in the same format. Busy capacity becomes Anthropic overloaded_error, OpenAI rate_limit_exceeded or Gemini RESOURCE_EXHAUSTED, with the message Upstream capacity for this model is busy. Retry shortly. Everything else becomes api_error (Gemini UNAVAILABLE) with The model provider returned an error. Please retry. On the Responses API, the event is an error event such as {"type": "error", "code": "server_error", "message": "…"}. Start the request over; output streamed before the error is charged.
Retry strategy#
Half Off Dev already moves a failing request to other channels before returning an error. Add your own retries for what's left:
- Retry on
429,500,502,503,504and network errors. - Wait for
retry-afterwhen the header is there. Otherwise back off exponentially, 1, 2, 4, 8 seconds and so on up to 30, with up to a second of random jitter so your workers don't retry in lockstep. - Give up after about five attempts and report the error.
- Don't repeat
400,401,403,404,413or422unchanged. A402only makes sense to retry after adding credit. - Remember timeouts. A request that timed out on your side may have completed and been charged. See Retries and idempotency.
The official Anthropic and OpenAI SDKs already retry 429 and 5xx twice with backoff; set max_retries (Python) or maxRetries (Node.js) on the client to change that. For raw HTTP calls, a helper like this does the job:
import os
import random
import time
import requests
RETRY_ON = {429, 500, 502, 503, 504}
def wait_time(attempt, retry_after=None):
if retry_after:
return float(retry_after)
return min(30, 2 ** (attempt - 1)) + random.random()
def call_with_retries(url, payload, attempts=5):
headers = {"Authorization": f"Bearer {os.environ['HALFOFF_API_KEY']}"}
for attempt in range(1, attempts + 1):
try:
resp = requests.post(url, json=payload, headers=headers, timeout=300)
except (requests.ConnectionError, requests.Timeout):
if attempt == attempts:
raise
time.sleep(wait_time(attempt))
continue
if resp.status_code in RETRY_ON and attempt < attempts:
time.sleep(wait_time(attempt, resp.headers.get("retry-after")))
continue
if not resp.ok: # a 4xx we shouldn't repeat, or the last attempt failed
raise RuntimeError(f"{resp.status_code}: {resp.text[:500]}")
return resp.json()
answer = call_with_retries(
"https://halfoff.dev/v1/chat/completions",
{
"model": "gpt-6.1-sol",
"messages": [{"role": "user", "content": "Name three uses for a paperclip."}],
},
)
print(answer["choices"][0]["message"]["content"])Whatever the format, error.message is written for humans. Log it together with the status code and the x-halfoff-request-id header.