---
title: Errors
description: 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-codes}

| 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 {#error-bodies}

The same `401` looks like this in each of the three formats:

```json title="Anthropic"
{"type": "error", "error": {"type": "authentication_error", "message": "Invalid or revoked API key"}}
```

```json title="OpenAI"
{"error": {"message": "Invalid or revoked API key", "type": "authentication_error", "code": "authentication_error"}}
```

```json title="Gemini"
{"error": {"code": 401, "message": "Invalid or revoked API key", "status": "UNAUTHENTICATED"}}
```

### Type and status for each code {#error-types}

| 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 {#error-messages}

| 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](/app/support).

## Errors inside a stream {#stream-errors}

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:

```sse title="Anthropic"
event: error
data: {"type": "error", "error": {"type": "api_error", "message": "Upstream stopped responding mid-stream"}}
```

```sse title="OpenAI"
data: {"error": {"message": "Upstream stopped responding mid-stream", "type": "api_error", "code": "upstream_stall"}}
```

```sse title="Gemini"
data: {"error": {"code": 504, "message": "Upstream stopped responding mid-stream", "status": "DEADLINE_EXCEEDED"}}
```

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 {#retry-failed-requests}

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`, `504` and network errors.
- **Wait** for `retry-after` when 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`, `413` or `422` unchanged. A `402` only 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](/docs/api#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:

```python title="Python"
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"])
```

```js title="Node.js"
const RETRY_ON = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitTime = (attempt, retryAfter) =>
  retryAfter ? Number(retryAfter) * 1000 : Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 1000;

async function callWithRetries(url, body, attempts = 5) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.HALFOFF_API_KEY}`,
          'content-type': 'application/json',
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(300_000),
      });
    } catch (err) {
      if (attempt >= attempts) throw err;
      await sleep(waitTime(attempt));
      continue;
    }
    if (RETRY_ON.has(res.status) && attempt < attempts) {
      await sleep(waitTime(attempt, res.headers.get('retry-after')));
      continue;
    }
    const text = await res.text();
    // A 4xx we shouldn't repeat, or the last attempt failed
    if (!res.ok) throw new Error(`${res.status}: ${text.slice(0, 500)}`);
    return JSON.parse(text);
  }
}

const answer = await callWithRetries('https://halfoff.dev/v1/chat/completions', {
  model: 'gpt-6.1-sol',
  messages: [{ role: 'user', content: 'Name three uses for a paperclip.' }],
});
console.log(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.
