Skip to main content

crabllm_core/
error.rs

1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3
4/// Shared error type for the crabllm workspace.
5///
6/// Every variant carries enough structure that retry semantics
7/// ([`Error::is_transient`]), HTTP status ([`Error::http_status`]), and the
8/// client-facing error `type` ([`Error::kind`]) are derivable from the variant
9/// alone — never from string contents. Layers must NOT stringify-and-rewrap an
10/// inner error (`Error::Internal(format!("...: {e}"))`); propagate the inner
11/// `Error` or classify the cause into the right variant instead.
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14    /// TOML config parse error or missing env var. Startup only.
15    #[error("config error: {0}")]
16    Config(String),
17
18    /// Upstream provider returned (or streamed) an error. `body` is the
19    /// upstream's own message, passed through verbatim — never prefixed.
20    #[error("provider error (HTTP {status}): {body}")]
21    Provider {
22        status: u16,
23        body: String,
24        retry_after: Option<Duration>,
25    },
26
27    /// Transport failure talking to the upstream: connect, send, or reading a
28    /// (possibly mid-stream) response body. Transient.
29    #[error("network error: {0}")]
30    Network(String),
31
32    /// An upstream response body could not be deserialized into our types.
33    /// Not transient — retrying yields the same unparseable bytes.
34    #[error("decode error: {0}")]
35    Decode(String),
36
37    /// Our own request could not be serialized. A bug on our side, not the
38    /// upstream's. Not transient.
39    #[error("encode error: {0}")]
40    Encode(String),
41
42    /// The client's request is malformed or violates a precondition (e.g. a
43    /// body that doesn't match the schema, or an unsupported option
44    /// combination). The client's fault — not transient, 400.
45    #[error("invalid request: {0}")]
46    Invalid(String),
47
48    /// A provider does not implement the requested operation.
49    #[error("unsupported: {0}")]
50    Unsupported(String),
51
52    /// Request could not be routed: unknown model or no matching deployment.
53    #[error("routing error: {0}")]
54    Routing(String),
55
56    /// Request to upstream provider timed out.
57    #[error("request timed out")]
58    Timeout,
59
60    /// Genuine catch-all for internal bugs. Not transient — if it happens,
61    /// retrying won't help.
62    #[error("internal error: {0}")]
63    Internal(String),
64}
65
66impl Error {
67    /// Whether this error is transient and the request should be retried.
68    /// Only network failures, timeouts, and upstream 429/5xx are transient;
69    /// everything else (decode, encode, unsupported, routing, internal) is
70    /// deterministic and must not be retried.
71    pub fn is_transient(&self) -> bool {
72        match self {
73            Error::Provider { status, .. } => matches!(status, 429 | 500 | 502 | 503 | 504),
74            Error::Network(_) | Error::Timeout => true,
75            _ => false,
76        }
77    }
78
79    /// Extract the retry-after duration from a `Provider` error, if present.
80    pub fn retry_after(&self) -> Option<Duration> {
81        match self {
82            Error::Provider { retry_after, .. } => *retry_after,
83            _ => None,
84        }
85    }
86
87    /// HTTP status to return to the client for this error. Single source of
88    /// truth for the proxy's error-to-response mapping.
89    pub fn http_status(&self) -> u16 {
90        match self {
91            Error::Provider { status, .. } => *status,
92            Error::Network(_) | Error::Decode(_) => 502,
93            Error::Unsupported(_) => 501,
94            Error::Routing(_) => 404,
95            Error::Invalid(_) => 400,
96            Error::Timeout => 504,
97            Error::Config(_) | Error::Encode(_) | Error::Internal(_) => 500,
98        }
99    }
100
101    /// OpenAI-compatible error `type` for the client-facing `ApiError`.
102    pub fn kind(&self) -> &'static str {
103        match self {
104            Error::Provider { .. } => "upstream_error",
105            Error::Network(_) => "network_error",
106            Error::Decode(_) => "decode_error",
107            Error::Routing(_) | Error::Invalid(_) => "invalid_request_error",
108            Error::Unsupported(_) => "unsupported_error",
109            Error::Timeout => "timeout_error",
110            Error::Config(_) | Error::Encode(_) | Error::Internal(_) => "server_error",
111        }
112    }
113
114    /// Build an "operation not supported" error for a provider trait method
115    /// that has no implementation. Used by `Provider` trait default impls.
116    /// Distinct from per-provider rejection messages so log lines can be
117    /// disambiguated by grep.
118    pub fn not_implemented(method: &str) -> Self {
119        Error::Unsupported(format!("provider method '{method}' not implemented"))
120    }
121}
122
123#[cfg(feature = "gateway")]
124impl From<toml::de::Error> for Error {
125    fn from(e: toml::de::Error) -> Self {
126        Error::Config(e.to_string())
127    }
128}
129
130/// OpenAI-compatible error response returned to clients.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
133pub struct ApiError {
134    pub error: ApiErrorBody,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
139pub struct ApiErrorBody {
140    pub message: String,
141    #[serde(rename = "type")]
142    pub kind: String,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub param: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub code: Option<String>,
147}
148
149impl ApiError {
150    pub fn new(message: impl Into<String>, kind: impl Into<String>) -> Self {
151        ApiError {
152            error: ApiErrorBody {
153                message: message.into(),
154                kind: kind.into(),
155                param: None,
156                code: None,
157            },
158        }
159    }
160}