SPB Git forge

spb/doc-api

Public
2commits 1branches 0releases
15.7 MBsize
maindefault branch
13 days agolast push
Python 88.3% TypeScript 7.6% Shell 4.1%
6.0 KB · 128 lines python
Raw Blame History
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