From 0cad1e161f58ee432969e6dd1db62748df4747eb Mon Sep 17 00:00:00 2001 From: Ed_ Date: Fri, 12 Jun 2026 18:32:05 -0400 Subject: [PATCH] refactor(ai_client): classifier functions return ErrorInfo instead of ProviderError The 6 error-classifier functions in ai_client.py, openai_compatible.py, and qwen_adapter.py now return ErrorInfo (data-oriented) instead of ProviderError. Each takes a source: str parameter for telemetry provenance. ProviderError class is still used in production code paths (Task 3.4) and will be removed in Task 3.7. --- src/ai_client.py | 80 ++++++++++++++++++++-------------------- src/openai_compatible.py | 23 ++++++------ src/qwen_adapter.py | 16 ++++---- 3 files changed, 61 insertions(+), 58 deletions(-) diff --git a/src/ai_client.py b/src/ai_client.py index cd0337dd..f70560b3 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -61,6 +61,7 @@ PROVIDERS: List[str] = ["gemini", "anthropic", "gemini_cli", "deepseek", "minima # existing call sites and the T3.1 test (which asserts # hasattr(src.ai_client, '_require_warmed')) continue to work. from src.module_loader import _require_warmed # noqa: E402,F401 +from src.result_types import ErrorInfo, ErrorKind # noqa: E402,F401 _provider: str = "gemini" @@ -358,42 +359,43 @@ def _load_credentials() -> dict[str, Any]: f"Or set SLOP_CREDENTIALS env var to a custom path." ) -def _classify_anthropic_error(exc: Exception) -> ProviderError: +def _classify_anthropic_error(exc: Exception, source: str = "ai_client.anthropic") -> ErrorInfo: try: anthropic = _require_warmed("anthropic") - if isinstance(exc, anthropic.RateLimitError): return ProviderError("rate_limit", "anthropic", exc) - if isinstance(exc, anthropic.AuthenticationError): return ProviderError("auth", "anthropic", exc) - if isinstance(exc, anthropic.PermissionDeniedError): return ProviderError("auth", "anthropic", exc) - if isinstance(exc, anthropic.APIConnectionError): return ProviderError("network", "anthropic", exc) + if isinstance(exc, anthropic.RateLimitError): return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc) + if isinstance(exc, anthropic.AuthenticationError): return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) + if isinstance(exc, anthropic.PermissionDeniedError): return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) + if isinstance(exc, anthropic.APIConnectionError): return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) if isinstance(exc, anthropic.APIStatusError): status = getattr(exc, "status_code", 0) body = str(exc).lower() - if status == 429: return ProviderError("rate_limit", "anthropic", exc) - if status in (401, 403): return ProviderError("auth", "anthropic", exc) - if status == 402: return ProviderError("balance", "anthropic", exc) - if "credit" in body or "balance" in body or "billing" in body: return ProviderError("balance", "anthropic", exc) - if "quota" in body or "limit" in body or "exceeded" in body: return ProviderError("quota", "anthropic", exc) + if status == 429: return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc) + if status in (401, 403): return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) + if status == 402: return ErrorInfo(kind=ErrorKind.BALANCE, message=str(exc), source=source, original=exc) + if "credit" in body or "balance" in body or "billing" in body: return ErrorInfo(kind=ErrorKind.BALANCE, message=str(exc), source=source, original=exc) + if "quota" in body or "limit" in body or "exceeded" in body: return ErrorInfo(kind=ErrorKind.QUOTA, message=str(exc), source=source, original=exc) except ImportError: pass - return ProviderError("unknown", "anthropic", exc) + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=str(exc), source=source, original=exc) -def _classify_gemini_error(exc: Exception) -> ProviderError: +def _classify_gemini_error(exc: Exception, source: str = "ai_client.gemini") -> ErrorInfo: body = str(exc).lower() try: - if isinstance(exc, gac.ResourceExhausted): return ProviderError("quota", "gemini", exc) - if isinstance(exc, gac.TooManyRequests): return ProviderError("rate_limit", "gemini", exc) - if isinstance(exc, (gac.Unauthenticated, gac.PermissionDenied)): return ProviderError("auth", "gemini", exc) - if isinstance(exc, gac.ServiceUnavailable): return ProviderError("network", "gemini", exc) - except ImportError: + gac = _require_warmed("google.api_core.exceptions") + if isinstance(exc, gac.ResourceExhausted): return ErrorInfo(kind=ErrorKind.QUOTA, message=str(exc), source=source, original=exc) + if isinstance(exc, gac.TooManyRequests): return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc) + if isinstance(exc, (gac.Unauthenticated, gac.PermissionDenied)): return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) + if isinstance(exc, gac.ServiceUnavailable): return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) + except (ImportError, AttributeError): pass - if "429" in body or "quota" in body or "resource exhausted" in body: return ProviderError("quota", "gemini", exc) - if "rate" in body and "limit" in body: return ProviderError("rate_limit", "gemini", exc) - if "401" in body or "403" in body or "api key" in body or "unauthenticated" in body: return ProviderError("auth", "gemini", exc) - if "402" in body or "billing" in body or "balance" in body or "payment" in body: return ProviderError("balance", "gemini", exc) - if "connection" in body or "timeout" in body or "unreachable" in body: return ProviderError("network", "gemini", exc) - return ProviderError("unknown", "gemini", exc) + if "429" in body or "quota" in body or "resource exhausted" in body: return ErrorInfo(kind=ErrorKind.QUOTA, message=str(exc), source=source, original=exc) + if "rate" in body and "limit" in body: return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc) + if "401" in body or "403" in body or "api key" in body or "unauthenticated" in body: return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) + if "402" in body or "billing" in body or "balance" in body or "payment" in body: return ErrorInfo(kind=ErrorKind.BALANCE, message=str(exc), source=source, original=exc) + if "connection" in body or "timeout" in body or "unreachable" in body: return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=str(exc), source=source, original=exc) -def _classify_deepseek_error(exc: Exception) -> ProviderError: +def _classify_deepseek_error(exc: Exception, source: str = "ai_client.deepseek") -> ErrorInfo: requests = _require_warmed("requests") body = "" if isinstance(exc, requests.exceptions.HTTPError) and exc.response is not None: @@ -408,16 +410,16 @@ def _classify_deepseek_error(exc: Exception) -> ProviderError: body = str(exc) body_l = body.lower() - if "429" in body_l or "rate" in body_l: return ProviderError("rate_limit", "deepseek", Exception(body)) - if "401" in body_l or "403" in body_l or "auth" in body_l or "api key" in body_l: return ProviderError("auth", "deepseek", Exception(body)) - if "402" in body_l or "balance" in body_l or "billing" in body_l: return ProviderError("balance", "deepseek", Exception(body)) - if "quota" in body_l or "limit exceeded" in body_l: return ProviderError("quota", "deepseek", Exception(body)) - if "connection" in body_l or "timeout" in body_l or "network" in body_l: return ProviderError("network", "deepseek", Exception(body)) + if "429" in body_l or "rate" in body_l: return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=body, source=source, original=exc) + if "401" in body_l or "403" in body_l or "auth" in body_l or "api key" in body_l: return ErrorInfo(kind=ErrorKind.AUTH, message=body, source=source, original=exc) + if "402" in body_l or "balance" in body_l or "billing" in body_l: return ErrorInfo(kind=ErrorKind.BALANCE, message=body, source=source, original=exc) + if "quota" in body_l or "limit exceeded" in body_l: return ErrorInfo(kind=ErrorKind.QUOTA, message=body, source=source, original=exc) + if "connection" in body_l or "timeout" in body_l or "network" in body_l: return ErrorInfo(kind=ErrorKind.NETWORK, message=body, source=source, original=exc) # If we have a body for a 400 error, wrap it - if "400" in body_l or "bad request" in body_l: return ProviderError("unknown", "deepseek", Exception(f"DeepSeek Bad Request: {body}")) - return ProviderError("unknown", "deepseek", Exception(body)) + if "400" in body_l or "bad request" in body_l: return ErrorInfo(kind=ErrorKind.UNKNOWN, message=f"DeepSeek Bad Request: {body}", source=source, original=exc) + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=body, source=source, original=exc) -def _classify_minimax_error(exc: Exception) -> ProviderError: +def _classify_minimax_error(exc: Exception, source: str = "ai_client.minimax") -> ErrorInfo: requests = _require_warmed("requests") body = "" if isinstance(exc, requests.exceptions.HTTPError) and exc.response is not None: @@ -431,14 +433,14 @@ def _classify_minimax_error(exc: Exception) -> ProviderError: body = str(exc) body_l = body.lower() - if "429" in body_l or "rate" in body_l: return ProviderError("rate_limit", "minimax", Exception(body)) - if "401" in body_l or "403" in body_l or "auth" in body_l or "api key" in body_l: return ProviderError("auth", "minimax", Exception(body)) - if "402" in body_l or "balance" in body_l or "billing" in body_l: return ProviderError("balance", "minimax", Exception(body)) - if "quota" in body_l or "limit exceeded" in body_l: return ProviderError("quota", "minimax", Exception(body)) - if "connection" in body_l or "timeout" in body_l or "network" in body_l: return ProviderError("network", "minimax", Exception(body)) + if "429" in body_l or "rate" in body_l: return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=body, source=source, original=exc) + if "401" in body_l or "403" in body_l or "auth" in body_l or "api key" in body_l: return ErrorInfo(kind=ErrorKind.AUTH, message=body, source=source, original=exc) + if "402" in body_l or "balance" in body_l or "billing" in body_l: return ErrorInfo(kind=ErrorKind.BALANCE, message=body, source=source, original=exc) + if "quota" in body_l or "limit exceeded" in body_l: return ErrorInfo(kind=ErrorKind.QUOTA, message=body, source=source, original=exc) + if "connection" in body_l or "timeout" in body_l or "network" in body_l: return ErrorInfo(kind=ErrorKind.NETWORK, message=body, source=source, original=exc) - if "400" in body_l or "bad request" in body_l: return ProviderError("unknown", "minimax", Exception(f"MiniMax Bad Request: {body}")) - return ProviderError("unknown", "minimax", Exception(body)) + if "400" in body_l or "bad request" in body_l: return ErrorInfo(kind=ErrorKind.UNKNOWN, message=f"MiniMax Bad Request: {body}", source=source, original=exc) + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=body, source=source, original=exc) def set_provider(provider: str, model: str, validate: bool = True) -> None: """ diff --git a/src/openai_compatible.py b/src/openai_compatible.py index 8625c78a..a484ee38 100644 --- a/src/openai_compatible.py +++ b/src/openai_compatible.py @@ -4,6 +4,8 @@ from typing import Any, Callable, Optional from openai import OpenAIError, RateLimitError, AuthenticationError, PermissionDeniedError, APIConnectionError, APIStatusError, BadRequestError +from src.result_types import ErrorInfo, ErrorKind + @dataclass(frozen=True) class NormalizedResponse: text: str @@ -36,27 +38,26 @@ def _to_dict_tool_call(tc: Any) -> dict[str, Any]: }, } -def _classify_openai_compatible_error(exc: Exception) -> "ProviderError": - from src.ai_client import ProviderError +def _classify_openai_compatible_error(exc: Exception, source: str = "openai_compatible") -> ErrorInfo: if isinstance(exc, RateLimitError): - return ProviderError(kind="rate_limit", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc) if isinstance(exc, AuthenticationError) or isinstance(exc, PermissionDeniedError): - return ProviderError(kind="auth", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) if isinstance(exc, APIConnectionError): - return ProviderError(kind="network", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) if isinstance(exc, APIStatusError): code = getattr(exc, "status_code", 0) if code == 402: - return ProviderError(kind="balance", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.BALANCE, message=str(exc), source=source, original=exc) if code == 429: - return ProviderError(kind="rate_limit", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc) if code in (401, 403): - return ProviderError(kind="auth", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) if code in (500, 502, 503, 504): - return ProviderError(kind="network", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) if isinstance(exc, BadRequestError): - return ProviderError(kind="quota", provider="openai_compatible", original=exc) - return ProviderError(kind="unknown", provider="openai_compatible", original=exc) + return ErrorInfo(kind=ErrorKind.QUOTA, message=str(exc), source=source, original=exc) + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=str(exc), source=source, original=exc) def send_openai_compatible( client: Any, diff --git a/src/qwen_adapter.py b/src/qwen_adapter.py index 358019e0..96ab6e4d 100644 --- a/src/qwen_adapter.py +++ b/src/qwen_adapter.py @@ -8,7 +8,7 @@ from dashscope.common.error import ( ServiceUnavailableError, TimeoutException, ) -from src.ai_client import ProviderError +from src.result_types import ErrorInfo, ErrorKind def build_dashscope_tools(openai_tools: list[dict[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] @@ -23,15 +23,15 @@ def build_dashscope_tools(openai_tools: list[dict[str, Any]]) -> list[dict[str, }) return out -def classify_dashscope_error(exc: Exception) -> ProviderError: +def classify_dashscope_error(exc: Exception, source: str = "qwen_adapter") -> ErrorInfo: if isinstance(exc, AuthenticationError): - return ProviderError(kind="auth", provider="qwen", original=exc) + return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc) if isinstance(exc, TimeoutException): - return ProviderError(kind="network", provider="qwen", original=exc) + return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) if isinstance(exc, ServiceUnavailableError): - return ProviderError(kind="network", provider="qwen", original=exc) + return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) if isinstance(exc, InvalidParameter): - return ProviderError(kind="quota", provider="qwen", original=exc) + return ErrorInfo(kind=ErrorKind.QUOTA, message=str(exc), source=source, original=exc) if isinstance(exc, RequestFailure): - return ProviderError(kind="network", provider="qwen", original=exc) - return ProviderError(kind="unknown", provider="qwen", original=exc) + return ErrorInfo(kind=ErrorKind.NETWORK, message=str(exc), source=source, original=exc) + return ErrorInfo(kind=ErrorKind.UNKNOWN, message=str(exc), source=source, original=exc)