"""Standardized error codes, categories, and unified error-response format. Every API error response follows the schema: { "code": "not_found", "detail": "Resource not found", "field": null, "trace_id": "a1b2c3d4", "retryable": false, "category": "permanent" } ``ErrorCategory`` classifies errors so callers can decide retry strategy. """ from __future__ import annotations from enum import StrEnum from typing import Any class ErrorCategory(StrEnum): """Error classification for retry decisions.""" TRANSIENT = "transient" # retryable: timeout, rate-limit, connection PERMANENT = "permanent" # non-retryable: validation, permission, not_found PARTIAL = "partial" # partly successful: batch, bulk operations ERROR_CODES: dict[str, dict[str, Any]] = { # ── Original 6 codes ── "not_found": {"status": 404, "message": "Resource not found", "category": ErrorCategory.PERMANENT, "retryable": False}, "permission_denied": {"status": 403, "message": "Permission denied", "category": ErrorCategory.PERMANENT, "retryable": False}, "validation_error": {"status": 422, "message": "Validation failed", "category": ErrorCategory.PERMANENT, "retryable": False}, "rate_limited": {"status": 429, "message": "Too many requests", "category": ErrorCategory.TRANSIENT, "retryable": True}, "internal_error": {"status": 500, "message": "Internal server error", "category": ErrorCategory.TRANSIENT, "retryable": True}, "service_unavailable": {"status": 503, "message": "Service temporarily unavailable", "category": ErrorCategory.TRANSIENT, "retryable": True}, # ── New codes (B-ERR-FMT) ── "forbidden": {"status": 403, "message": "Forbidden", "category": ErrorCategory.PERMANENT, "retryable": False}, "conflict": {"status": 409, "message": "Conflict with current state", "category": ErrorCategory.PERMANENT, "retryable": False}, "unprocessable": {"status": 422, "message": "Unprocessable entity", "category": ErrorCategory.PERMANENT, "retryable": False}, "not_implemented": {"status": 501, "message": "Not implemented", "category": ErrorCategory.PERMANENT, "retryable": False}, "service_timeout": {"status": 504, "message": "Service timed out", "category": ErrorCategory.TRANSIENT, "retryable": True}, "bad_gateway": {"status": 502, "message": "Bad gateway", "category": ErrorCategory.TRANSIENT, "retryable": True}, # ── Partial success ── "partial_success": {"status": 207, "message": "Partial success", "category": ErrorCategory.PARTIAL, "retryable": False}, } class ApiError(Exception): """Application-level error with code, category, and retryable flag. Attributes: code: Error code key from ``ERROR_CODES``. detail: Human-readable detail message. field: Optional field name that caused the error. status: HTTP status code. category: ``ErrorCategory`` for retry decisions. retryable: Whether the caller may retry the request. """ def __init__( self, code: str, detail: str | None = None, field: str | None = None, status: int | None = None, category: ErrorCategory | None = None, retryable: bool | None = None, ): meta = ERROR_CODES.get(code, {}) self.code = code self.detail = detail or meta.get("message", "Unknown error") self.field = field self.status = status or meta.get("status", 500) self.category = category or meta.get("category", ErrorCategory.TRANSIENT) self.retryable = retryable if retryable is not None else meta.get("retryable", True) super().__init__(self.detail) def to_response(self, trace_id: str | None = None) -> dict[str, Any]: """Build the unified error-response dict.""" resp: dict[str, Any] = { "code": self.code, "detail": self.detail, "field": self.field, "trace_id": trace_id, "retryable": self.retryable, "category": self.category.value if isinstance(self.category, ErrorCategory) else str(self.category), } return resp # ── Exception classification helper ────────────────────────────────────────── # Transient error indicators _TRANSIENT_KEYWORDS = frozenset({ "timeout", "timed out", "rate limit", "rate_limit", "429", "503", "502", "504", "service unavailable", "overloaded", "connection reset", "connection aborted", "temporary", "transient", "retry", }) # Permanent error indicators _PERMANENT_KEYWORDS = frozenset({ "authentication", "auth", "401", "403", "unauthorized", "forbidden", "invalid api key", "invalid_api_key", "validation", "invalid_request", "400", "bad request", "model_not_found", "not found", "404", "409", "conflict", "not implemented", "501", "permission", }) # Partial error indicators _PARTIAL_KEYWORDS = frozenset({ "partial", "batch", "bulk", "some failed", "multi-status", "207", }) def classify_exception(exc: Exception) -> ErrorCategory: """Classify an exception into an ``ErrorCategory``. Uses string matching on the exception message and type name. Falls back to ``ErrorCategory.TRANSIENT`` for unknown errors (safer to retry). Args: exc: The exception to classify. Returns: ``ErrorCategory.TRANSIENT``, ``ErrorCategory.PERMANENT``, or ``ErrorCategory.PARTIAL``. """ # If it's already an ApiError, use its category if isinstance(exc, ApiError): return exc.category if isinstance(exc.category, ErrorCategory) else ErrorCategory(exc.category) import asyncio as _asyncio msg = str(exc).lower() exc_type_name = type(exc).__name__.lower() # Check partial first — batch/bulk errors if any(kw in msg or kw in exc_type_name for kw in _PARTIAL_KEYWORDS): return ErrorCategory.PARTIAL # Check permanent — auth/validation/permission errors should never be retried if any(kw in msg or kw in exc_type_name for kw in _PERMANENT_KEYWORDS): return ErrorCategory.PERMANENT # Check transient if any(kw in msg or kw in exc_type_name for kw in _TRANSIENT_KEYWORDS): return ErrorCategory.TRANSIENT # asyncio.TimeoutError is always transient if isinstance(exc, (_asyncio.TimeoutError, TimeoutError, ConnectionError)): return ErrorCategory.TRANSIENT # Default: treat as transient (safe to retry) return ErrorCategory.TRANSIENT def build_error_response( code: str, detail: str | None = None, field: str | None = None, status: int | None = None, trace_id: str | None = None, ) -> dict[str, Any]: """Build a unified error-response dict without raising an exception.""" meta = ERROR_CODES.get(code, {}) return { "code": code, "detail": detail or meta.get("message", "Unknown error"), "field": field, "trace_id": trace_id, "retryable": meta.get("retryable", True), "category": meta.get("category", ErrorCategory.TRANSIENT).value if isinstance(meta.get("category"), ErrorCategory) else str(meta.get("category", ErrorCategory.TRANSIENT)), }