"""Provider-agnostic error classification helper (OpenAI first, Anthropic shape supported). STATUS: LIVE_VERIFIED for the OpenAI shapes listed in generated/fragments/errors/openai-errors.json (the classifier is unit-tested offline in tests/openai/test_errors.py against bodies captured on 2026-09-18). Usage: from classify_error import classify, ErrorClass st, body, headers = ... # raw HTTP status + parsed JSON body + response headers c = classify("openai", st, body, headers) if c.retryable: sleep(c.retry_after or backoff(attempt)) """ from __future__ import annotations import random from dataclasses import dataclass, field from typing import Any, Mapping # error.code values that are NOT retryable even though they arrive with a retryable-looking status (429) NON_RETRYABLE_429_CODES = { "insufficient_quota", "credit_balance_exhausted", "organization_spend_limit_exceeded", "project_spend_limit_exceeded", "organization_usage_limit_exceeded", "billing_hard_limit_reached", } RETRYABLE_STATUSES = {408, 409, 425, 429, 500, 502, 503, 504, 529} # Codes that mean "fix the request" regardless of status CLIENT_BUG_CODES = { "invalid_type", "missing_required_parameter", "unknown_parameter", "unsupported_parameter", "invalid_value", "invalid_json", "unsupported_content_type", "integer_below_min_value", "mutually_exclusive_parameters", "context_length_exceeded", "invalid_beta", "model_not_found", "invalid_api_key", "mismatched_organization", "invalid_project", "insufficient_permissions", "ip_not_authorized", "unsupported_country_region_territory", "misalignment_policy_violation", "cyber_policy", "moderation_blocked", "content_policy_violation", "invalid_prompt", } @dataclass class ErrorClass: provider: str http_status: int | None type: str | None code: str | None param: str | None message: str category: str # auth | permission | not_found | bad_request | rate_limit | quota_billing | safety | server | network | unknown retryable: bool retry_after: float | None = None # seconds, from Retry-After / retry-after-ms when present request_id: str | None = None raw: Any = field(default=None, repr=False) def backoff(self, attempt: int, base: float = 0.5, cap: float = 8.0) -> float: """Delay to sleep before retry number `attempt` (1-based): server hint first, else exp. backoff + jitter.""" if self.retry_after is not None: return self.retry_after + random.uniform(0, 0.25) return min(cap, base * 2 ** (attempt - 1)) * (1 + random.uniform(-0.25, 0.25)) def _hdr(headers: Mapping[str, str] | None, name: str) -> str | None: if not headers: return None for k, v in headers.items(): if k.lower() == name: return v return None def _retry_after(headers: Mapping[str, str] | None) -> float | None: ms = _hdr(headers, "retry-after-ms") if ms: try: return float(ms) / 1000 except ValueError: pass ra = _hdr(headers, "retry-after") if ra: try: return float(ra) except ValueError: # HTTP-date form: let the caller fall back to backoff return None return None def classify(provider: str, status: int | None, body: Any, headers: Mapping[str, str] | None = None) -> ErrorClass: """Normalise an error response. Handles OpenAI's three body shapes: {"error": {"message","type","param","code"}} | {"error": ""} (admin 403) | empty body (edge 404) and Anthropic's {"type":"error","error":{"type","message"}} (request_id in header `request-id`). """ err: Any = None if isinstance(body, dict): err = body.get("error", body) elif isinstance(body, (str, bytes)) and body: err = body.decode() if isinstance(body, bytes) else body etype = code = param = None message = "" if isinstance(err, dict): etype, code, param = err.get("type"), err.get("code"), err.get("param") message = str(err.get("message") or "") elif isinstance(err, str): message = err rid = _hdr(headers, "x-request-id") or _hdr(headers, "request-id") retry_after = _retry_after(headers) # ---- category ---- if status is None: category, retryable = "network", True elif status == 401 or code in {"invalid_api_key", "mismatched_organization", "invalid_project"} or etype == "authentication_error": category, retryable = "auth", False elif status == 403 or etype == "permission_error" or code in {"insufficient_permissions", "ip_not_authorized"}: category = "safety" if code in {"misalignment_policy_violation", "cyber_policy"} else "permission" retryable = False elif status == 404 or etype == "not_found_error" or code in {"model_not_found", "not_found_error"}: category, retryable = "not_found", False elif status == 429: if code in NON_RETRYABLE_429_CODES or etype == "insufficient_quota": category, retryable = "quota_billing", False else: category, retryable = "rate_limit", True # rate_limit_exceeded, slow_down, anthropic rate_limit_error elif status is not None and status >= 500: category, retryable = "server", True # server_error, service_unavailable_error/server_is_overloaded, overloaded_error elif status in (408, 409, 425): category, retryable = "server", True elif code in {"moderation_blocked", "content_policy_violation", "invalid_prompt"} or etype == "image_generation_user_error": category, retryable = "safety", False elif status is not None and 400 <= status < 500: category = "bad_request" retryable = status == 422 and code not in CLIENT_BUG_CODES # docs: 422 "try again"; everything else is a client bug else: category, retryable = "unknown", status in RETRYABLE_STATUSES if status else False if "Missing scopes" in message: category, retryable = "permission", False return ErrorClass(provider, status, etype, code, param, message, category, retryable, retry_after, rid, body)