File size: 3,476 Bytes
79c4c46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
"""Error taxonomy shared by the agent service, the internal API, and the UI.



Every error carries a stable ``code``, an HTTP status for the internal API, and

a message that is safe to show to users (no keys, URLs, or prompt content).

"""

from __future__ import annotations

import asyncio

import httpx


class ClearCastError(Exception):
    """Base error with a stable code and a client-safe message."""

    code = "internal_error"
    http_status = 500
    retryable = False

    def __init__(

        self,

        message: str,

        *,

        code: str | None = None,

        http_status: int | None = None,

        retryable: bool | None = None,

    ) -> None:
        super().__init__(message)
        self.message = message
        if code is not None:
            self.code = code
        if http_status is not None:
            self.http_status = http_status
        if retryable is not None:
            self.retryable = retryable


class ServiceUnavailableError(ClearCastError):
    code = "service_unavailable"
    http_status = 503
    retryable = True


class ConfigurationError(ServiceUnavailableError):
    code = "configuration_missing"
    retryable = False


class ToolServiceUnavailableError(ServiceUnavailableError):
    """The MCP weather tool subprocess could not be started or reached."""

    code = "tool_service_unavailable"


class ModelProviderError(ClearCastError):
    code = "model_provider_error"
    http_status = 502


class SessionBusyError(ClearCastError):
    code = "session_busy"
    http_status = 429
    retryable = True


class PlanNotFoundError(ClearCastError):
    code = "plan_not_found"
    http_status = 404


class ReviewConflictError(ClearCastError):
    code = "review_conflict"
    http_status = 409


def classify_model_exception(exc: BaseException) -> ModelProviderError | None:
    """Map OpenAI/httpx/timeout failures to a categorised, client-safe error."""
    import openai

    if isinstance(exc, openai.APITimeoutError | httpx.TimeoutException | asyncio.TimeoutError):
        return ModelProviderError(
            "The language model did not respond in time.",
            code="model_timeout",
            http_status=504,
            retryable=True,
        )
    if isinstance(exc, openai.RateLimitError):
        return ModelProviderError(
            "The language model provider is rate limiting requests.",
            code="model_rate_limited",
            http_status=503,
            retryable=True,
        )
    if isinstance(exc, openai.AuthenticationError | openai.PermissionDeniedError):
        return ModelProviderError(
            "The language model provider rejected the configured credentials.",
            code="model_auth_error",
        )
    if isinstance(exc, openai.APIConnectionError):
        return ModelProviderError(
            "The language model provider could not be reached.",
            code="model_unavailable",
            retryable=True,
        )
    if isinstance(exc, openai.BadRequestError):
        return ModelProviderError("The language model provider rejected the request.", code="model_bad_request")
    if isinstance(exc, openai.APIStatusError):
        return ModelProviderError(
            f"The language model provider returned an error (HTTP {exc.status_code}).",
            retryable=exc.status_code >= 500,
        )
    return None