|
1 | | -# Errors |
| 1 | +# Error Handling |
2 | 2 |
|
3 | | -## Normalized Error Classes |
| 3 | +## Error hierarchy |
4 | 4 |
|
5 | | -- `AuthError` |
6 | | -- `RateLimitError` |
7 | | -- `TimeoutError` |
8 | | -- `InvalidRequestError` |
9 | | -- `ServerError` |
10 | | -- `UnsupportedFeatureError` |
11 | | -- `UnsupportedModelError` |
12 | | -- `NotConfiguredError` |
13 | | -- `ProviderError` (fallback) |
| 5 | +``` |
| 6 | +ULMError |
| 7 | +├── TransportError # Network-level: DNS, connection refused, socket timeout |
| 8 | +└── ProviderError # API-level: the provider returned an error |
| 9 | + ├── AuthError # 401, 403 — bad key, expired key, wrong permissions |
| 10 | + ├── BillingError # 402 — payment or billing issue |
| 11 | + ├── RateLimitError # 429 — too many requests or quota exceeded |
| 12 | + ├── InvalidRequestError # 400, 404, 409, 413, 422 — bad model, bad params, not found |
| 13 | + │ └── ContextLengthError # input exceeds the model's context window |
| 14 | + ├── TimeoutError # 408, 504 — request timed out |
| 15 | + ├── ServerError # 5xx — provider is down or overloaded |
| 16 | + ├── UnsupportedModelError |
| 17 | + ├── UnsupportedFeatureError |
| 18 | + └── NotConfiguredError |
| 19 | +``` |
14 | 20 |
|
15 | | -## Mapping |
| 21 | +`ContextLengthError` extends `InvalidRequestError` — existing `except InvalidRequestError` catches still work. |
16 | 22 |
|
17 | | -`map_http_error(status, body)` maps HTTP status to normalized class. |
| 23 | +## How errors flow |
18 | 24 |
|
19 | | -Adapters may override `normalize_error()` for provider-specific body parsing while preserving normalized class hierarchy. |
| 25 | +``` |
| 26 | +Provider HTTP response (status >= 400) |
| 27 | + → Adapter.normalize_error(status, body) # each adapter parses its own error format |
| 28 | + → map_http_error(status, message) # maps HTTP status → error class |
| 29 | + → AuthError / RateLimitError / etc. |
| 30 | +``` |
| 31 | + |
| 32 | +Each adapter owns its error parsing via `normalize_error`. The base `map_http_error` only maps status codes to error classes — adapters extract the human-readable message. |
| 33 | + |
| 34 | +Streaming follows the same taxonomy. Provider stream errors are normalized to canonical stream error codes (`auth`, `billing`, `rate_limit`, `invalid_request`, `context_length`, `timeout`, `server`, `provider`) and `lm15.stream.Stream` raises the corresponding typed `ProviderError` subclass. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## Provider error mapping |
| 39 | + |
| 40 | +### OpenAI |
| 41 | + |
| 42 | +Source of truth: [platform.openai.com/docs/guides/error-codes](https://platform.openai.com/docs/guides/error-codes) |
| 43 | + |
| 44 | +Error shape: `{"error": {"message": "...", "type": "...", "param": "...", "code": "..."}}` |
| 45 | + |
| 46 | +| HTTP | `error.type` | `error.code` | lm15 error | Detection | |
| 47 | +|------|-------------|--------------|------------|-----------| |
| 48 | +| 400 | `invalid_request_error` | `context_length_exceeded` | `ContextLengthError` | ✅ structured `code` field | |
| 49 | +| 400 | `invalid_request_error` | `model_not_found` | `InvalidRequestError` | | |
| 50 | +| 400 | `invalid_request_error` | `missing_required_parameter` | `InvalidRequestError` | | |
| 51 | +| 400 | `invalid_request_error` | `invalid_value` | `InvalidRequestError` | | |
| 52 | +| 401 | `invalid_request_error` | `invalid_api_key` | `AuthError` | | |
| 53 | +| 403 | `invalid_request_error` | *(project access denied)* | `AuthError` | | |
| 54 | +| 429 | `rate_limit_error` | `rate_limit_exceeded` | `RateLimitError` | | |
| 55 | +| 429 | `insufficient_quota` | `insufficient_quota` | `BillingError` | ✅ structured `code`/`type` field | |
| 56 | +| 500 | `server_error` | *(varies)* | `ServerError` | | |
| 57 | +| 503 | `server_error` | `service_unavailable` | `ServerError` | | |
| 58 | + |
| 59 | +The Responses API can also return **in-band errors** on HTTP 200 via `Response.error` (e.g. background task failures): |
| 60 | + |
| 61 | +| `Response.error.code` | lm15 error | |
| 62 | +|-----------------------|------------| |
| 63 | +| `server_error` | `ServerError` | |
| 64 | +| `rate_limit_exceeded` | `RateLimitError` | |
| 65 | +| `invalid_prompt` | `InvalidRequestError` | |
| 66 | +| *(other)* | `ServerError` (fallback) | |
| 67 | + |
| 68 | +### Anthropic |
| 69 | + |
| 70 | +Source of truth: [docs.anthropic.com/en/api/errors](https://docs.anthropic.com/en/api/errors) |
| 71 | + |
| 72 | +Error shape: `{"type": "error", "error": {"type": "...", "message": "..."}, "request_id": "..."}` |
| 73 | + |
| 74 | +| HTTP | `error.type` | lm15 error | Notes | |
| 75 | +|------|-------------|------------|-------| |
| 76 | +| 400 | `invalid_request_error` | `InvalidRequestError` | | |
| 77 | +| 400 | `invalid_request_error` | `ContextLengthError` | ⚠️ message match: `"prompt is too long"`. No structured code exists | |
| 78 | +| 401 | `authentication_error` | `AuthError` | | |
| 79 | +| 402 | `billing_error` | `BillingError` | | |
| 80 | +| 403 | `permission_error` | `AuthError` | | |
| 81 | +| 404 | `not_found_error` | `InvalidRequestError` | | |
| 82 | +| 413 | `request_too_large` | `InvalidRequestError` | Payload > 32 MB (from Cloudflare, before API) | |
| 83 | +| 429 | `rate_limit_error` | `RateLimitError` | | |
| 84 | +| 500 | `api_error` | `ServerError` | | |
| 85 | +| 504 | `timeout_error` | `TimeoutError` | | |
| 86 | +| 529 | `overloaded_error` | `ServerError` | API-wide overload | |
| 87 | + |
| 88 | +### Google Gemini |
| 89 | + |
| 90 | +Source of truth: [ai.google.dev/gemini-api/docs/troubleshooting](https://ai.google.dev/gemini-api/docs/troubleshooting) and [Google API error model](https://cloud.google.com/apis/design/errors) |
| 91 | + |
| 92 | +Error shape: `{"error": {"message": "...", "code": <int>, "status": "...", "details": [...]}}` |
| 93 | + |
| 94 | +| HTTP | `error.status` | lm15 error | Notes | |
| 95 | +|------|---------------|------------|-------| |
| 96 | +| 400 | `INVALID_ARGUMENT` | `InvalidRequestError` | | |
| 97 | +| 400 | `FAILED_PRECONDITION` | `BillingError` | "Free tier not available in your country. Enable billing." | |
| 98 | +| 403 | `PERMISSION_DENIED` | `AuthError` | | |
| 99 | +| 404 | `NOT_FOUND` | `InvalidRequestError` | | |
| 100 | +| 429 | `RESOURCE_EXHAUSTED` | `RateLimitError` | | |
| 101 | +| 429 | `RESOURCE_EXHAUSTED` | `ContextLengthError` | ⚠️ message match: token + limit/exceed keywords. Gemini conflates context overflow with rate limiting under the same 429/RESOURCE_EXHAUSTED | |
| 102 | +| 500 | `INTERNAL` | `ServerError` | | |
| 103 | +| 500 | `INTERNAL` | `ContextLengthError` | ⚠️ message match: "input context is too long" — Gemini docs list this under 500/INTERNAL | |
| 104 | +| 503 | `UNAVAILABLE` | `ServerError` | | |
| 105 | +| 504 | `DEADLINE_EXCEEDED` | `TimeoutError` | Prompt/context too large to process in time | |
| 106 | + |
| 107 | +Gemini `generateContent` can also return in-band generation failures on HTTP 200: |
| 108 | + |
| 109 | +| Signal | lm15 error | |
| 110 | +|--------|------------| |
| 111 | +| `promptFeedback.blockReason` set | `InvalidRequestError` | |
| 112 | +| `candidate.finishReason` in safety/recitation/blocklist/prohibited/tool-malformed classes | `InvalidRequestError` | |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +## Context length detection reliability |
| 117 | + |
| 118 | +| Provider | Detection method | Reliability | |
| 119 | +|----------|-----------------|-------------| |
| 120 | +| OpenAI | `error.code == "context_length_exceeded"` | ✅ Structured, reliable | |
| 121 | +| Anthropic | Message contains `"prompt is too long"` | ⚠️ String matching — could break if Anthropic changes wording | |
| 122 | +| Gemini | Message contains token + limit/exceed keywords | ⚠️ String matching — Gemini conflates context overflow with `RESOURCE_EXHAUSTED` (429), same as rate limiting | |
| 123 | + |
| 124 | +Only OpenAI provides a machine-readable error code for context overflow. Anthropic and Gemini require message parsing. If detection fails, the error falls through to `InvalidRequestError` (Anthropic) or `RateLimitError` (Gemini), which are still catchable. |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## Catching errors |
| 129 | + |
| 130 | +```python |
| 131 | +import lm15 |
| 132 | +from lm15.errors import AuthError, BillingError, RateLimitError, InvalidRequestError, ContextLengthError, ServerError |
| 133 | + |
| 134 | +try: |
| 135 | + resp = lm15.complete("gpt-4.1-mini", "Hello.", env=".env") |
| 136 | +except ContextLengthError as e: |
| 137 | + print(f"Input too long: {e}") |
| 138 | +except AuthError as e: |
| 139 | + print(f"Bad credentials: {e}") |
| 140 | +except BillingError as e: |
| 141 | + print(f"Payment issue: {e}") |
| 142 | +except RateLimitError as e: |
| 143 | + print(f"Slow down or add credits: {e}") |
| 144 | +except InvalidRequestError as e: |
| 145 | + print(f"Fix your request: {e}") |
| 146 | +except ServerError as e: |
| 147 | + print(f"Provider is down, retry later: {e}") |
| 148 | +``` |
| 149 | + |
| 150 | +> **Order matters.** Catch `ContextLengthError` before `InvalidRequestError` — it's a subclass. |
| 151 | +
|
| 152 | +### Catch all provider errors |
| 153 | + |
| 154 | +```python |
| 155 | +from lm15.errors import ProviderError |
| 156 | + |
| 157 | +try: |
| 158 | + resp = lm15.complete("gpt-4.1-mini", "Hello.", env=".env") |
| 159 | +except ProviderError as e: |
| 160 | + print(f"Provider error: {e}") |
| 161 | +``` |
| 162 | + |
| 163 | +### Catch everything (including network issues) |
| 164 | + |
| 165 | +```python |
| 166 | +from lm15.errors import ULMError |
| 167 | + |
| 168 | +try: |
| 169 | + resp = lm15.complete("gpt-4.1-mini", "Hello.", env=".env") |
| 170 | +except ULMError as e: |
| 171 | + print(f"Something went wrong: {e}") |
| 172 | +``` |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +## For plugin authors |
| 177 | + |
| 178 | +Override `normalize_error` on your adapter to parse your provider's error format: |
| 179 | + |
| 180 | +```python |
| 181 | +import json |
| 182 | +from lm15.errors import ProviderError, map_http_error |
| 183 | +from lm15.providers.base import BaseProviderAdapter |
| 184 | + |
| 185 | +class MyAdapter(BaseProviderAdapter): |
| 186 | + def normalize_error(self, status: int, body: str) -> ProviderError: |
| 187 | + try: |
| 188 | + data = json.loads(body) |
| 189 | + msg = data.get("error_description", body[:200]) |
| 190 | + except Exception: |
| 191 | + msg = body[:200] or f"HTTP {status}" |
| 192 | + return map_http_error(status, msg) |
| 193 | +``` |
| 194 | + |
| 195 | +`map_http_error` handles the status → error class mapping. You only need to extract the human-readable message. For provider-specific error types (like context length), detect them in your `normalize_error` and return the appropriate lm15 error class directly. |
0 commit comments