Skip to content

Mapping Error Response Shapes Between Chat APIs

9 min read · updated August 11, 2026

Error handling is the part of a provider migration nobody writes an adapter for, because the happy path is what gets tested. Then the first rate limit arrives, the code reaches for err.error.code, finds a field that is a number instead of a string on the new provider, and the retry logic does not fire.

The four error bodies

Here are the envelopes, with a 400 from each so the nesting is directly comparable. The message text varies; the structure does not.

OpenAI (both Chat Completions and Responses):
{
  "error": {
    "message": "Invalid value for 'temperature': must be <= 2",
    "type": "invalid_request_error",
    "param": "temperature",
    "code": null
  }
}

Anthropic Messages:
{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "temperature: must be <= 1"
  }
}

Google Gemini:
{
  "error": {
    "code": 400,
    "message": "Invalid JSON payload received.",
    "status": "INVALID_ARGUMENT",
    "details": []
  }
}

Three differences do real damage. Anthropic’s body has a type at the top level whose value is the constant "error" — a discriminator for the envelope, not a description of the failure — and a second type inside error which is the one you want. Code that reads body.type gets the string “error” on every failure and cannot branch on it.

OpenAI is the only one of the three with a param field, and it is the most useful field in any of these bodies: it names the request field that was rejected. Nothing carries that information on the other two, so a validation-error UI that highlights the offending input degrades to a message string when you switch.

And Google follows the standard Google API error shape, which means code is the numeric HTTP status duplicated into the body, while status is the symbolic name. That is the reverse of the OpenAI convention, where code is a short symbolic string and there is no numeric field at all.

What code means on each side

“Code” is the field most likely to be mapped straight across and it means three different things.

  • OpenAI: error.code is a nullable short string identifying a specific condition — things like context_length_exceeded, rate_limit_exceeded, insufficient_quota, model_not_found. It is null for many generic validation failures, so it cannot be the only thing you branch on. error.type is the broader class.
  • Anthropic: there is no code. error.type is the only machine-readable classifier, drawn from a documented set: invalid_request_error, authentication_error, permission_error, not_found_error, request_too_large, rate_limit_error, api_error and overloaded_error. The granularity that OpenAI puts in code lives only in the human-readable message.
  • Google: error.status is the canonical gRPC status name — INVALID_ARGUMENT, RESOURCE_EXHAUSTED, PERMISSION_DENIED, UNAVAILABLE — and is the field to switch on. details is an array of typed objects and is where anything structured actually lives.

The practical consequence is that a condition like “the prompt is longer than the context window” is a distinct code on one provider, an invalid_request_error with an informative message on another, and something you may have to detect from message text on a third. Detecting from message text is fragile and you should say so in a comment where you do it, because the strings are not part of any contract and change without notice.

Status codes and what is retryable

The HTTP status is the one thing that maps reasonably well, with one conspicuous exception. 400 is a bad request everywhere, 401 is authentication, 403 is permission, 404 is an unknown model or endpoint, 429 is a rate limit, 500 is the provider’s fault.

The exception is overload. Anthropic returns 529 with an overloaded_error for “we are busy, try again” — a status outside the standard range, which some HTTP clients, proxies and retry libraries do not classify as retryable because nothing in their table mentions it. Google signals the same condition with 503 UNAVAILABLE, OpenAI generally with 429 or 503. If your retry policy is a list of status codes, 529 has to be on it explicitly.

Anthropic also uses 413 with request_too_large for an oversized request body, which is a size limit on the HTTP payload rather than a token-count limit and so is not fixed by trimming the conversation slightly. And every provider may send a retry-after header on a 429; honour it in preference to your own backoff, because your backoff does not know when the window resets. The general treatment of backoff is in the page on testing 429 backoff.

Errors that arrive after a 200

This is the case that survives every migration untested. Once a streaming response has begun, the status line is already sent and cannot be changed. An error that occurs after that point has to be delivered inside the stream, and it therefore looks nothing like the bodies above.

Anthropic’s streaming reference documents this directly: the API may emit an error event mid-stream, and gives the example of an overloaded_error that would have been a 529 on a non-streaming call.

event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}

So the same failure reaches your code as an HTTP status on one path and as a parsed SSE frame on another, and only the second path is one your error mapper will not see unless you route it there deliberately. OpenAI-shaped streams have the same problem with less ceremony: an error may appear as a data frame containing an error object instead of a chunk, and some servers simply close the connection.

A truncated stream with no error frame at all is the worst version, because it is indistinguishable from a complete one unless you check the terminal frame. That is the strongest practical argument for asserting on it: on an OpenAI-shaped stream, require the literal data: [DONE] line; on Anthropic, require message_stop; on the Responses API, require response.completed. A stream that ends without its terminal frame is a failure, and should raise the same way a 500 does. See testing that a stream closes cleanly.

One error type across providers

The adapter worth writing is narrow. Do not try to preserve every provider field; preserve the ones a caller can act on, and keep the original for the log.

type ProviderError = {
  kind: "auth" | "invalid_request" | "not_found" | "rate_limit"
      | "too_large" | "overloaded" | "server" | "unknown";
  retryable: boolean;      // derived from kind, plus retry-after if present
  retryAfterMs: number | null;
  param: string | null;    // only OpenAI populates this; null elsewhere
  message: string;         // provider text, unmodified
  provider: string;
  raw: unknown;            // the entire original body, kept verbatim
};

One decision inside that type is worth arguing about, and it is what kind should be derived from. Deriving it from the HTTP status alone is not enough, because 400 covers both a malformed body and a context-length overflow, and those want different handling — one is a bug in your code, the other is a prompt you can trim and resend. Deriving it from the provider’s classifier alone does not work either, because Anthropic has no code field and Google’s status names are coarse. Use both: status first for the broad bucket, then the provider classifier to narrow it, and only fall back to matching on message text where you have no other signal — with a comment saying so, because that branch will break silently when the wording changes.

Two further disciplines make it useful. Derive retryable from kind in one place rather than at each call site, so adding 529 to the overloaded bucket fixes every retry loop at once. And keep raw: the field you did not map is always the one you need when a provider changes something, and reconstructing it from a log line that recorded only the message is not possible.