DEV Community

Mattias chaw
Mattias chaw

Posted on

Build an Error Taxonomy for Multi-Model AI API Gateways

Build an Error Taxonomy for Multi-Model AI API Gateways

Most AI gateway failures are not mysterious. They become expensive because every provider names them differently, every SDK retries them differently, and every team debates them after the incident instead of classifying them before dispatch.

A multi-model gateway that routes across DeepSeek, Qwen, GLM, Kimi, and other OpenAI-compatible surfaces needs more than try again later. It needs a shared error taxonomy that tells the SDK whether to retry, reroute, cap output, ask for user action, or stop immediately. It also needs to attach dated pricing metadata, because a retry policy is partly a cost policy.

This article shows how to build that taxonomy as a small production primitive. The goal is not to hide provider behavior. The goal is to normalize it enough that engineers, support, and finance can explain what happened without reading raw provider responses one by one.

All source and pricing checks below were refreshed on August 22, 2026. AIWave's public pricing pages currently list DeepSeek V4 Flash at $0.638 input, $1.914 output, and $0.0203 cache-hit input per 1M tokens; DeepSeek V4 Pro at $1.914 input, $5.742 output, and $0.0638 cache-hit input per 1M tokens. AIWave keeps those all-day rows separate from official DeepSeek peak and off-peak rows on its predictable-pricing page, so the claim should be operational predictability and unified routing, not a universal price-floor claim. QwenCloud's pricing docs describe pay-as-you-go billing, context caching, Batch API behavior, tool fees, and failed-call billing behavior. Z.AI lists GLM rows with separate cached-input pricing, and Kimi K3 public material lists long-context pricing with cache-hit and cache-miss input rows.

Why Error Names Are Not Enough

If your gateway only stores HTTP status codes, you will lose context fast. A 429 can mean account-level concurrency, tenant-level throttling, provider capacity, or a bursty internal worker queue. A 400 can mean a malformed message, an unsupported tool schema, a context overflow, or a model that does not support a requested mode. A timeout can mean provider latency, a broken stream, a user-aborted request, or a local network path.

Those cases should not have the same behavior. Some deserve a fast retry. Some should reroute to another model family. Some should stop because repeating the call only adds latency and possibly cost. Some should become user-facing errors because the request needs to be changed.

A taxonomy gives every failure a stable internal meaning:

Provider symptom Gateway class Default action
Authentication failure auth_config Stop and alert operators.
Unsupported model or mode capability_mismatch Stop or reroute only if policy allows.
Context window exceeded context_overflow Summarize, trim, or ask caller to reduce context.
Rate limit or concurrency limit capacity_limit Retry with jitter or reroute by policy.
Provider 5xx provider_unavailable Retry within budget, then reroute or fail closed.
Stream ended early stream_interrupted Retry only if idempotent and output is discardable.
Tool schema rejected tool_schema_error Stop and return developer-facing diagnostics.
Billing or quota denial commercial_block Stop and notify account owner.

The important part is not the exact names. The important part is that they are few, stable, and actionable.

The Three Policies Behind Every Retry

Retries look like reliability logic, but they are also product and finance logic. A retry can improve success rate. It can also create duplicate work, longer waits, and avoidable token spend. A useful taxonomy therefore separates three policies.

First, classify the error. This answers what happened in gateway terms. Second, decide whether the request is retryable. This depends on idempotency, task type, stream state, and provider signal. Third, decide whether the retry budget still allows another call. That budget should use dated rate-card rows, not guesses.

For example, a non-streaming JSON extraction call may be safe to retry once after a provider 5xx. A streaming coding assistant that already emitted partial instructions may not be safe to replay unless the client can discard the partial response. A tool-call planning request may be safe to reroute from a Flash model to a Pro model only if the policy says quality matters more than cost for that task class.

The retry decision should return structured metadata:

{
  "error_class": "capacity_limit",
  "retryable": true,
  "reroute_allowed": true,
  "retry_after_ms": 1800,
  "max_attempts": 2,
  "cost_budget_usd": 0.02,
  "policy_version": "error-taxonomy-2026-08-22",
  "rate_card_source_date": "2026-08-22"
}
Enter fullscreen mode Exit fullscreen mode

That metadata can be written beside the final usage record. Later, when a customer asks why a request took 11 seconds or why a fallback model was used, you have the answer.

Rate Cards Belong in Error Handling

Do not let the retry layer treat every model as equal. A retry against a short-output Flash route and a retry against a long-context route are different budget events. This becomes especially important when providers expose separate fields for fresh input, cached input, output, tool calls, context tiers, or failed-call behavior.

At minimum, store these fields for the route that failed:

Field Purpose
model The selected provider-facing model name.
route_family Internal family such as DeepSeek, Qwen, GLM, or Kimi.
input_per_m Fresh input price per 1M tokens for this route.
cached_input_per_m Cache-hit input price when available.
output_per_m Output price per 1M tokens.
source_url The pricing page or internal dated rate-card source.
source_date The date the row was checked.
failed_call_policy Whether failed calls can still affect billing or quotas.

The failed_call_policy field is easy to skip and hard to reconstruct later. If a provider documents that some failed calls can affect billing, quota, or tool charges, your retry budget should know that before it fires another request. If a provider does not expose enough detail, store unknown and keep the retry budget conservative.

Python Example: Classify, Budget, Retry

The following Python example shows a compact gateway layer. It classifies provider errors, estimates retry exposure with a dated AIWave DeepSeek row, and returns a decision the caller can log.

import os
import random
import time
from dataclasses import dataclass, asdict
from typing import Literal

from openai import OpenAI

client = OpenAI(
    api_key=os.environ.get("AIWAVE_API_KEY", "YOUR_API_KEY_HERE"),
    base_url="https://aiwave.live/v1",
)

ErrorClass = Literal[
    "auth_config",
    "capability_mismatch",
    "context_overflow",
    "capacity_limit",
    "provider_unavailable",
    "stream_interrupted",
    "tool_schema_error",
    "commercial_block",
    "unknown",
]

@dataclass(frozen=True)
class RateCard:
    model: str
    input_per_m: float
    cached_input_per_m: float
    output_per_m: float
    source_url: str
    source_date: str

@dataclass(frozen=True)
class ErrorDecision:
    error_class: ErrorClass
    retryable: bool
    reroute_allowed: bool
    max_attempts: int
    retry_after_ms: int
    estimated_retry_usd: float
    policy_version: str
    reason: str

AIWAVE_FLASH = RateCard(
    model="deepseek-v4-flash",
    input_per_m=0.638,
    cached_input_per_m=0.0203,
    output_per_m=1.914,
    source_url="https://aiwave.live/pricing",
    source_date="2026-08-22",
)

def classify_error(status_code: int | None, body: str) -> ErrorClass:
    text = body.lower()
    if status_code in {401, 403}:
        return "auth_config"
    if status_code == 402 or "quota" in text or "balance" in text:
        return "commercial_block"
    if status_code == 404 or "model" in text and "not found" in text:
        return "capability_mismatch"
    if "context" in text and ("length" in text or "window" in text):
        return "context_overflow"
    if status_code == 429 or "rate limit" in text or "concurrency" in text:
        return "capacity_limit"
    if status_code and 500 <= status_code < 600:
        return "provider_unavailable"
    if "tool" in text and "schema" in text:
        return "tool_schema_error"
    return "unknown"

def estimate_retry_cost(
    row: RateCard,
    input_tokens: int,
    cached_input_tokens: int,
    output_cap: int,
) -> float:
    fresh_input_tokens = max(input_tokens - cached_input_tokens, 0)
    cost = (
        fresh_input_tokens / 1_000_000 * row.input_per_m
        + cached_input_tokens / 1_000_000 * row.cached_input_per_m
        + output_cap / 1_000_000 * row.output_per_m
    )
    return round(cost, 6)

def decide_error_policy(
    error_class: ErrorClass,
    row: RateCard,
    input_tokens: int,
    cached_input_tokens: int,
    output_cap: int,
    already_streamed: bool,
) -> ErrorDecision:
    estimated_retry_usd = estimate_retry_cost(
        row, input_tokens, cached_input_tokens, output_cap
    )

    if error_class in {"auth_config", "commercial_block", "tool_schema_error"}:
        return ErrorDecision(
            error_class, False, False, 0, 0, 0.0,
            "error-taxonomy-2026-08-22",
            "caller_or_account_action_required",
        )

    if error_class == "context_overflow":
        return ErrorDecision(
            error_class, False, True, 0, 0, 0.0,
            "error-taxonomy-2026-08-22",
            "summarize_or_route_to_approved_long_context_model",
        )

    if already_streamed:
        return ErrorDecision(
            error_class, False, False, 0, 0, estimated_retry_usd,
            "error-taxonomy-2026-08-22",
            "partial_output_already_released",
        )

    if error_class in {"capacity_limit", "provider_unavailable"}:
        return ErrorDecision(
            error_class, estimated_retry_usd <= 0.02, True, 2,
            random.randint(1200, 3200), estimated_retry_usd,
            "error-taxonomy-2026-08-22",
            "bounded_retry_with_jitter",
        )

    return ErrorDecision(
        error_class, False, False, 0, 0, estimated_retry_usd,
        "error-taxonomy-2026-08-22",
        "unclassified_errors_fail_closed",
    )

def chat_with_error_policy(prompt: str):
    input_tokens = max(1, len(prompt) // 4)
    output_cap = 1200
    attempts = 0

    while True:
        attempts += 1
        try:
            return client.chat.completions.create(
                model=AIWAVE_FLASH.model,
                messages=[{"role": "user", "content": prompt}],
                max_tokens=output_cap,
            )
        except Exception as exc:
            status = getattr(exc, "status_code", None)
            body = str(exc)
            error_class = classify_error(status, body)
            decision = decide_error_policy(
                error_class,
                AIWAVE_FLASH,
                input_tokens=input_tokens,
                cached_input_tokens=0,
                output_cap=output_cap,
                already_streamed=False,
            )
            print(asdict(decision))
            if not decision.retryable or attempts >= decision.max_attempts:
                raise
            time.sleep(decision.retry_after_ms / 1000)
Enter fullscreen mode Exit fullscreen mode

This is not a complete gateway. It is the smallest useful loop: classify, estimate, decide, log, then retry only when the policy allows it.

In production, replace rough token estimation with the tokenizer your gateway uses. Record actual usage after the call. Add provider-specific adapters that preserve raw status, raw body, request id, and response headers in private logs while exposing only the normalized class to product code.

Rerouting Is Not a Blind Fallback

Rerouting is often treated as an automatic reliability feature. It should be more deliberate. If a DeepSeek Flash execution step hits capacity, rerouting to another execution-capable model may be fine. If a Pro planning step fails after a long prompt, rerouting to a different family can change quality, latency, privacy posture, and unit economics.

Use explicit reroute rules:

Task class Reroute allowed? Notes
Short extraction Yes Keep schema strict and output capped.
Coding patch generation Yes, with replay test coverage Log model family and policy version.
Legal or compliance review Review required Model change may affect evidence handling.
Customer support draft Yes Discard partial streamed output before retry.
Tool-call planning Limited Tool schema compatibility must be checked first.

The reroute rule should be attached to the original request, not invented after the error. That makes behavior reproducible. It also prevents an incident from becoming a hidden model migration.

What to Show the Customer

Do not expose raw provider errors by default. They may include internal identifiers, confusing provider names, or messages that encourage the wrong user action. Instead, map normalized classes to product-safe responses.

For context_overflow, explain that the request needs less context or a summarization step. For capacity_limit, say the route is temporarily constrained and the request may be retried. For tool_schema_error, return a developer-facing message with the rejected field and the supported schema shape. For commercial_block, ask the account owner to check billing or plan status.

Support staff should have a richer view: normalized class, raw provider status, request id, selected model, fallback model, source date for rate card, retry count, estimated retry cost, actual usage, and policy version. That is enough to answer most tickets without exposing prompt content.

Observability Fields That Matter

Add these fields to every gateway span:

Span field Example
ai.route.model deepseek-v4-flash
ai.route.family deepseek
ai.error.class capacity_limit
ai.retry.count 1
ai.retry.estimated_usd 0.0042
ai.rate_card.source_date 2026-08-22
ai.policy.version error-taxonomy-2026-08-22
ai.fallback.used false
ai.stream.partial_released false

These fields let you build dashboards that show failure mix by model, retry spend by tenant, capacity incidents by provider family, and policy changes by release. They also let you separate provider instability from client misuse. A spike in tool_schema_error after an SDK release is not the same problem as a spike in provider_unavailable.

Rollout Plan

Start in shadow mode. Classify every error and log the decision you would have made, but do not change retry behavior yet. Run this for a week across Tier 1 and Tier 2 production traffic, especially US, UK, Germany, Japan, Singapore, and similar developer-heavy markets where reliability expectations are high.

Next, enable the taxonomy for one task class. Short extraction or non-streaming support drafts are good candidates because partial output is easier to discard. Keep max attempts low. Add an alert when retry spend crosses a small daily threshold.

Then add reroute rules for selected model families. Require a replay test for every reroute pair. A replay test should include successful calls, context overflow, schema mismatch, provider 5xx, capacity limits, and streaming interruption. Store the policy version beside every replay result.

Finally, make the taxonomy part of release review. Any new model, provider, SDK feature, or pricing row should answer four questions before launch:

Question Required answer
Which error classes can this route emit? Provider adapter mapping exists.
Which classes are retryable? Policy table has task-level rules.
What is the retry cost ceiling? Dated rate-card row is attached.
Can fallback change behavior? Replay fixtures cover the fallback pair.

Source Links for Implementation

If you are building this on AIWave, start with the Chat Completions documentation, model catalog, pricing page, and predictable-pricing calculator. The model catalog and pricing pages give the route and rate-card context your error policy needs.

For external source checks, keep current links to the official DeepSeek pricing docs, DeepSeek rate-limit docs, QwenCloud pricing docs, Z.AI pricing docs, and Kimi K3 page. Recheck these pages before changing retry budgets or fallback policies.

Final Checklist

Before you ship an error taxonomy for a multi-model AI gateway, make sure it can do the following:

Requirement Pass condition
Normalize provider errors Every raw error maps to a stable class or unknown.
Separate retry from reroute A retry decision does not automatically change model families.
Track stream state Partial output changes retry behavior.
Attach dated rate cards Retry budgets include source date and route price fields.
Preserve raw diagnostics privately Operators can debug without exposing internals to users.
Version policies Every decision stores a policy version.
Replay fallback paths Model-pair behavior is tested before production rollout.

The useful shift is cultural as much as technical. Error handling is not an afterthought at the edge of the SDK. It is a routing surface. Once failures are classified, budgeted, and versioned, the gateway can make boring decisions under pressure: retry when it is safe, reroute when policy allows it, stop when the caller must act, and keep every decision tied to a dated source.

Top comments (0)