Skip to content

Normalising Errors Across Heterogeneous APIs

6 min read · updated August 3, 2026

Every provider has its own error vocabulary, its own choice of status codes for the same condition, and its own opinion about what belongs in the body. Handling them individually produces a switch statement that grows forever and is wrong in a different way for each vendor.

Nine dialects, one switch statement

The same underlying condition can arrive as a 429 with a retry-after header, a 429 with a code in the body and no header, a 503, or a 200 whose body contains an error object. A refusal on content policy might be a 400, or a 200 with an empty completion and a finish reason you have to inspect. A context-length problem might be a 400 with a descriptive message and no machine-readable code at all.

The consequence is that any code branching on a vendor’s status code is subtly wrong for every other vendor, and — worse — subtly wrong for the same vendor after they change something. It also means your dashboards group by strings that are not comparable, so “what is our error rate” has no answer.

There is a second, quieter cost. Without a normalised type, every decision that depends on an error — retry or not, fall back or not, trip the breaker or not, show the user a refusal or an outage — has to re-derive the answer from the raw payload at the point where the decision is made. Those derivations drift apart. The retry code decides a particular 400 is worth another attempt; the breaker code decides the same error is a provider fault; the user-facing code calls it an outage. All three are looking at the same bytes and reaching different conclusions, and no test catches it because each is individually plausible.

Classify by response, not by cause

The instinct is to build a taxonomy of causes: rate limits, authentication, validation, server errors. That is the vendor’s organisation, and it is not what your code needs. Your code needs to know what to do, and there are only a handful of possible actions.

  • Try this again, as-is, after a delay.
  • Try a different route, immediately.
  • Change the request and try again.
  • Stop, and tell the user something specific.
  • Stop, and page someone.

Five actions. So the taxonomy has roughly as many classes, and every class answers “which action” without further inspection. That is the test for whether a taxonomy is any good: if a class requires the caller to look at the original error to decide what to do, it is not a class, it is a label.

This is also why the classes below are not a one-to-one mapping of HTTP status codes. Two conditions that arrive as the same status can demand different actions — a 400 for a malformed body and a 400 for an over-length prompt have nothing in common operationally — and two conditions that arrive as different statuses can demand the same one. Let the action decide the boundaries, and accept that the mapping function will contain some unglamorous string matching as a result.

The taxonomy

ClassDescription
transientConnection failures, timeouts, 5xx. Retry with backoff on the same route, subject to the safety rules about billable retries. Trips the circuit breaker.
rate_limitedRetry after a delay, and slow the whole client down, not just this call. Carries retryAfterMs when the provider supplied one. Does not trip the breaker.
route_unavailableModel deprecated, region down, breaker open, provider refusing this model. Do not retry here; fall back to another rung immediately.
input_invalidMalformed request, unsupported parameter, bad schema. Never retry — it will fail identically. A rise in this class means you shipped a bug.
input_too_longIts own class, separate from input_invalid, because it has a distinct remedy: truncate, summarise, or route to a longer-context rung. This is the class most often lost inside a generic 400.
content_filteredThe request or the response was refused on policy grounds. Not an outage. Usually reproduces on other models, so falling back wastes money; surface it as a refusal.
authBad, revoked or wrong-scope credentials. Never retry, always alert. Silently degrading past this is how an expired key stays unnoticed for a week.
quota_exhaustedOut of credit, or over a spend cap. A billing state, not a technical one: route around this provider and notify a human. A backoff timer will not resolve it.
unknownEverything unmatched. Treated as fatal by default, logged with the full original payload, and reviewed — see below.

The normaliser

export type NormalError = {
  class: ErrorClass;
  retryable: boolean;          // derived from class; never set by hand
  retryAfterMs?: number;
  route: string;               // which provider/model produced it
  provider: { status?: number; code?: string; message?: string };  // kept, always
  raw: unknown;                // the untouched payload, for the unknown case
};

export function normalise(err: unknown, route: string): NormalError {
  const at = (c: ErrorClass, extra: Partial<NormalError> = {}) =>
    ({ class: c, retryable: RETRYABLE.has(c), route, provider: view(err), raw: err, ...extra });

  if (isNetworkError(err) || isAbort(err)) return at("transient");

  const status = statusOf(err);
  const code = (codeOf(err) ?? "").toLowerCase();
  const text = (messageOf(err) ?? "").toLowerCase();

  if (status === 429) return at("rate_limited", { retryAfterMs: retryAfterOf(err) });
  if (status === 401 || status === 403) return at("auth");
  if (status === 402 || code.includes("insufficient_quota")) return at("quota_exhausted");
  if (status && status >= 500) return at("transient");
  if (status === 404 || code.includes("model_not_found")) return at("route_unavailable");

  if (status === 400 || status === 422) {
    // The message sniffing here is unavoidable and it is fragile, so it is
    // confined to one function and covered by tests built from real payloads.
    if (code.includes("context_length") || text.includes("maximum context"))
      return at("input_too_long");
    if (code.includes("content_filter") || code.includes("content_policy"))
      return at("content_filtered");
    return at("input_invalid");
  }

  return at("unknown");        // NOT transient. See below.
}

Three design points. retryable is derived from the class rather than passed in, so there is exactly one place where the retry semantics of a class are defined and no call site can disagree with it. The original payload is always retained — a normalised error that discards the vendor’s message makes debugging strictly harder than no normalisation at all. And the string matching is quarantined in one function, because it is the part that will break when a provider rewords a message, and you want it to break in one place with a test around it.

Note also the cases that are not HTTP errors at all: a 200 with a finish reason of content_filter, a 200 whose body fails schema validation, a stream that ends without a terminal event. Feed all of those through the same normaliser so that downstream code sees one type. An error that arrives as a successful response is still an error.

The unknown class is the important one

There is a strong temptation to make the default case retryable, on the grounds that a retry is harmless and might work. With a metered dependency it is not harmless, and the failure mode is specific: suppose a provider introduces a new error for a condition that will never succeed. Defaulting to retryable means every occurrence is attempted three times, at full price, forever, and because the calls eventually fail the feature looks broken rather than expensive. Both problems, simultaneously.

Default to fatal instead. A fatal misclassification costs you one failed request that would probably have failed anyway; a retryable misclassification costs you money proportional to your traffic. The asymmetry is not close.

Then make unknown visible. Log it with the full payload, count it as its own metric, and alert when its rate rises — an increase in unclassified errors is the earliest signal that a provider changed something, and it usually arrives before anyone reads a changelog. Each one you review becomes either a new mapping rule or a test case confirming the existing behaviour, so the class shrinks over time. Treat it as a queue, not as a bucket.

Normalising Errors Across Heterogeneous APIs · Multigrid