2026-08-13 21:33:14 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-08-16 01:17:18 +02:00
|
|
|
from enum import StrEnum
|
2026-08-13 21:33:14 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 01:17:18 +02:00
|
|
|
class ErrorCategory(StrEnum):
|
2026-08-13 21:33:14 +02:00
|
|
|
"""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},
|
2026-07-26 12:49:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ApiError(Exception):
|
2026-08-13 21:33:14 +02:00
|
|
|
"""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, {})
|
2026-07-26 12:49:39 +02:00
|
|
|
self.code = code
|
2026-08-13 21:33:14 +02:00
|
|
|
self.detail = detail or meta.get("message", "Unknown error")
|
2026-07-26 12:49:39 +02:00
|
|
|
self.field = field
|
2026-08-13 21:33:14 +02:00
|
|
|
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)
|
2026-07-26 12:49:39 +02:00
|
|
|
super().__init__(self.detail)
|
2026-08-13 21:33:14 +02:00
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
}
|