halfoff.devDocs
Get a key
API reference

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#

StatusNameTypical causeRetry?
400Bad requestBody 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
401UnauthenticatedKey missing, mistyped, revoked or expiredNo, check the key
402Payment requiredBalance at $0, or the key reached its spend limitOnly after topping up or raising the limit
403ForbiddenModel isn't on the key's allow-listNo
404Not foundUnknown model id, a model not served in this API format, an unknown endpoint or the wrong HTTP methodNo
413Payload too largePrompt too long for the modelNo, shorten it
422UnprocessableThe model refused the request's contentNo
429Too many requestsKey rate or concurrency limit, or the model's capacity is busyYes, after retry-after
500Internal errorUnexpected error in Half Off DevYes, with backoff
502Bad gatewayThe model failed on every channel Half Off Dev triedYes, 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#

StatusAnthropic error.typeOpenAI error.type and error.codeGemini error.status
400invalid_request_errorinvalid_request_errorINVALID_ARGUMENT
401authentication_errorauthentication_errorUNAUTHENTICATED
402billing_errorinsufficient_quotaRESOURCE_EXHAUSTED
403permission_errorpermission_deniedPERMISSION_DENIED
404not_found_error, or not_found for an unknown endpointnot_found_error, or not_found for an unknown endpointNOT_FOUND
413, 422invalid_request_errorinvalid_request_errorINVALID_ARGUMENT
429rate_limit_errorrate_limit_exceededRESOURCE_EXHAUSTED
500api_errorapi_errorINTERNAL
502, 503api_errorapi_errorUNAVAILABLE
504api_errorapi_errorDEADLINE_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#

Statusmessage
400Body must be JSON
400Unknown group 'xyz'
400The model provider's content filter refused this request. Rewording it or using another model may help.
400, 413, 422The model's own explanation, for example prompt is too long
401Missing API key, Invalid or revoked API key, This API key has expired
402Insufficient balance. Top up at the dashboard.
402This API key has reached its spend limit.
403This API key is not allowed to use 'gpt-6.1-sol'.
404Model 'gpt-9' is not available. See GET /v1/models.
404Unknown endpoint /v1/embeddings, No route for GET /v1/messages
429This API key has reached its rate or concurrency limit.
429All upstream capacity for this model is busy. Retry shortly.
502The 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, 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.

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.