Python 88.3%
TypeScript 7.6%
Shell 4.1%
1"""Provider-agnostic error classification helper (OpenAI first, Anthropic shape supported).2STATUS: LIVE_VERIFIED for the OpenAI shapes listed in generated/fragments/errors/openai-errors.json3(the classifier is unit-tested offline in tests/openai/test_errors.py against bodies captured on 2026-09-18).45Usage:6 from classify_error import classify, ErrorClass7 st, body, headers = ... # raw HTTP status + parsed JSON body + response headers8 c = classify("openai", st, body, headers)9 if c.retryable: sleep(c.retry_after or backoff(attempt))10"""11from __future__ import annotations1213import random14from dataclasses import dataclass, field15from typing import Any, Mapping1617# error.code values that are NOT retryable even though they arrive with a retryable-looking status (429)18NON_RETRYABLE_429_CODES = {19 "insufficient_quota", "credit_balance_exhausted", "organization_spend_limit_exceeded",20 "project_spend_limit_exceeded", "organization_usage_limit_exceeded", "billing_hard_limit_reached",21}22RETRYABLE_STATUSES = {408, 409, 425, 429, 500, 502, 503, 504, 529}23# Codes that mean "fix the request" regardless of status24CLIENT_BUG_CODES = {25 "invalid_type", "missing_required_parameter", "unknown_parameter", "unsupported_parameter", "invalid_value",26 "invalid_json", "unsupported_content_type", "integer_below_min_value", "mutually_exclusive_parameters",27 "context_length_exceeded", "invalid_beta", "model_not_found", "invalid_api_key", "mismatched_organization",28 "invalid_project", "insufficient_permissions", "ip_not_authorized", "unsupported_country_region_territory",29 "misalignment_policy_violation", "cyber_policy", "moderation_blocked", "content_policy_violation", "invalid_prompt",30}313233@dataclass34class ErrorClass:35 provider: str36 http_status: int | None37 type: str | None38 code: str | None39 param: str | None40 message: str41 category: str # auth | permission | not_found | bad_request | rate_limit | quota_billing | safety | server | network | unknown42 retryable: bool43 retry_after: float | None = None # seconds, from Retry-After / retry-after-ms when present44 request_id: str | None = None45 raw: Any = field(default=None, repr=False)4647 def backoff(self, attempt: int, base: float = 0.5, cap: float = 8.0) -> float:48 """Delay to sleep before retry number `attempt` (1-based): server hint first, else exp. backoff + jitter."""49 if self.retry_after is not None:50 return self.retry_after + random.uniform(0, 0.25)51 return min(cap, base * 2 ** (attempt - 1)) * (1 + random.uniform(-0.25, 0.25))525354def _hdr(headers: Mapping[str, str] | None, name: str) -> str | None:55 if not headers:56 return None57 for k, v in headers.items():58 if k.lower() == name:59 return v60 return None616263def _retry_after(headers: Mapping[str, str] | None) -> float | None:64 ms = _hdr(headers, "retry-after-ms")65 if ms:66 try:67 return float(ms) / 100068 except ValueError:69 pass70 ra = _hdr(headers, "retry-after")71 if ra:72 try:73 return float(ra)74 except ValueError: # HTTP-date form: let the caller fall back to backoff75 return None76 return None777879def classify(provider: str, status: int | None, body: Any, headers: Mapping[str, str] | None = None) -> ErrorClass:80 """Normalise an error response. Handles OpenAI's three body shapes:81 {"error": {"message","type","param","code"}} | {"error": "<bare string>"} (admin 403) | empty body (edge 404)82 and Anthropic's {"type":"error","error":{"type","message"}} (request_id in header `request-id`).83 """84 err: Any = None85 if isinstance(body, dict):86 err = body.get("error", body)87 elif isinstance(body, (str, bytes)) and body:88 err = body.decode() if isinstance(body, bytes) else body89 etype = code = param = None90 message = ""91 if isinstance(err, dict):92 etype, code, param = err.get("type"), err.get("code"), err.get("param")93 message = str(err.get("message") or "")94 elif isinstance(err, str):95 message = err96 rid = _hdr(headers, "x-request-id") or _hdr(headers, "request-id")97 retry_after = _retry_after(headers)9899 # ---- category ----100 if status is None:101 category, retryable = "network", True102 elif status == 401 or code in {"invalid_api_key", "mismatched_organization", "invalid_project"} or etype == "authentication_error":103 category, retryable = "auth", False104 elif status == 403 or etype == "permission_error" or code in {"insufficient_permissions", "ip_not_authorized"}:105 category = "safety" if code in {"misalignment_policy_violation", "cyber_policy"} else "permission"106 retryable = False107 elif status == 404 or etype == "not_found_error" or code in {"model_not_found", "not_found_error"}:108 category, retryable = "not_found", False109 elif status == 429:110 if code in NON_RETRYABLE_429_CODES or etype == "insufficient_quota":111 category, retryable = "quota_billing", False112 else:113 category, retryable = "rate_limit", True # rate_limit_exceeded, slow_down, anthropic rate_limit_error114 elif status is not None and status >= 500:115 category, retryable = "server", True # server_error, service_unavailable_error/server_is_overloaded, overloaded_error116 elif status in (408, 409, 425):117 category, retryable = "server", True118 elif code in {"moderation_blocked", "content_policy_violation", "invalid_prompt"} or etype == "image_generation_user_error":119 category, retryable = "safety", False120 elif status is not None and 400 <= status < 500:121 category = "bad_request"122 retryable = status == 422 and code not in CLIENT_BUG_CODES # docs: 422 "try again"; everything else is a client bug123 else:124 category, retryable = "unknown", status in RETRYABLE_STATUSES if status else False125 if "Missing scopes" in message:126 category, retryable = "permission", False127 return ErrorClass(provider, status, etype, code, param, message, category, retryable, retry_after, rid, body)128