Skip to main content

llm/
error.rs

1use std::fmt;
2
3use thiserror::Error;
4
5#[doc = include_str!("docs/llm_error.md")]
6#[derive(Debug, Error, Clone)]
7pub enum LlmError {
8    #[error(transparent)]
9    ReasoningValidation(#[from] crate::catalog::ReasoningEffortError),
10    #[error("Disabling reasoning is not implemented for model '{model}' on this transport")]
11    UnsupportedDisableTransport { model: String },
12    /// Environment variable not set or invalid
13    #[error("{0} environment variable not set")]
14    MissingApiKey(String),
15    /// Normalized provider-side failure carrying retry classification and
16    /// support diagnostics (HTTP status, request ID, provider error code).
17    #[error("{0}")]
18    Provider(#[from] ProviderError),
19    /// JSON parsing/serialization error
20    #[error("JSON parsing error: {0}")]
21    JsonParsing(String),
22    /// Tool parameter parsing error
23    #[error("Failed to parse tool parameters for {tool_name}: {error}")]
24    ToolParameterParsing { tool_name: String, error: String },
25    /// OAuth authentication error
26    #[error("OAuth error: {0}")]
27    OAuthError(String),
28
29    /// The Codex access token is not a JWT carrying a `ChatGPT` account ID.
30    #[error("Invalid JWT: {0}")]
31    InvalidJwt(String),
32    /// The message contained only content types this provider doesn't support
33    #[error("Unsupported content: {0}")]
34    UnsupportedContent(String),
35    /// Provider endpoint URL has not been configured.
36    #[error("Provider '{provider}' requires a URL configured via providers.{provider}.url")]
37    MissingProviderUrl { provider: String },
38    /// Provider name is not registered with the model parser.
39    #[error("Unknown provider: {provider}")]
40    UnknownProvider { provider: String },
41    /// The model spec did not yield any usable provider.
42    #[error("No models provided")]
43    EmptyModelSpec,
44    /// A single-model-only config field was reused across multiple models of the
45    /// same provider within one alloy spec (e.g. bedrock `inferenceProfileArn`
46    /// or openai-compatible `requestModel`).
47    #[error("providers.{provider}.{field} cannot be used with multiple {provider} models in one alloy spec")]
48    DuplicateProvider { provider: String, field: String },
49    /// A `provider:model` identity could not be parsed.
50    #[error("Invalid model spec: {0}")]
51    InvalidModelSpec(String),
52    /// A provider request body could not be constructed (e.g. an SDK builder
53    /// rejected the input or contained malformed data).
54    #[error("Failed to build provider request: {0}")]
55    ProviderRequest(String),
56    /// An upstream client library rejected an argument as invalid.
57    #[error("Invalid argument: {0}")]
58    InvalidArgument(String),
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ProviderErrorKind {
63    Authentication,
64    Api,
65    RateLimit,
66    Server,
67    Timeout,
68    Network,
69    StreamInterrupted,
70    Unknown,
71}
72
73impl ProviderErrorKind {
74    pub fn is_retryable(&self) -> bool {
75        matches!(
76            self,
77            Self::RateLimit | Self::Server | Self::Timeout | Self::Network | Self::StreamInterrupted | Self::Unknown
78        )
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ProviderError {
84    pub kind: ProviderErrorKind,
85    pub message: String,
86    pub http_status: Option<u16>,
87    pub request_id: Option<String>,
88    pub code: Option<String>,
89}
90
91impl ProviderError {
92    pub fn new(kind: ProviderErrorKind, message: impl Into<String>) -> Self {
93        Self { kind, message: message.into(), http_status: None, request_id: None, code: None }
94    }
95
96    pub fn authentication(message: impl Into<String>) -> Self {
97        Self::new(ProviderErrorKind::Authentication, message)
98    }
99
100    pub fn api(message: impl Into<String>) -> Self {
101        Self::new(ProviderErrorKind::Api, message)
102    }
103
104    pub fn rate_limit(message: impl Into<String>) -> Self {
105        Self::new(ProviderErrorKind::RateLimit, message)
106    }
107
108    pub fn server(message: impl Into<String>) -> Self {
109        Self::new(ProviderErrorKind::Server, message)
110    }
111
112    pub fn timeout(message: impl Into<String>) -> Self {
113        Self::new(ProviderErrorKind::Timeout, message)
114    }
115
116    pub fn network(message: impl Into<String>) -> Self {
117        Self::new(ProviderErrorKind::Network, message)
118    }
119
120    pub fn stream_interrupted(message: impl Into<String>) -> Self {
121        Self::new(ProviderErrorKind::StreamInterrupted, message)
122    }
123
124    pub fn from_http_status(status: u16, message: impl Into<String>) -> Self {
125        match status {
126            401 | 403 => Self::authentication(message),
127            408 | 504 => Self::timeout(message),
128            429 => Self::rate_limit(message),
129            s if (500..600).contains(&s) => Self::server(message),
130            _ => Self::api(message),
131        }
132        .with_http_status(status)
133    }
134
135    pub fn with_http_status(mut self, status: u16) -> Self {
136        self.http_status = Some(status);
137        self
138    }
139
140    pub fn with_http_metadata(mut self, status: Option<u16>, request_id: Option<String>) -> Self {
141        self.http_status = status;
142        self.request_id = request_id;
143        self
144    }
145
146    pub fn with_code(mut self, code: Option<String>) -> Self {
147        self.code = code;
148        self
149    }
150
151    pub fn with_request_id(mut self, request_id: Option<String>) -> Self {
152        self.request_id = request_id;
153        self
154    }
155
156    pub fn is_retryable(&self) -> bool {
157        self.kind.is_retryable()
158    }
159}
160
161impl fmt::Display for ProviderError {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        let prefix = match self.kind {
164            ProviderErrorKind::Authentication => "Authentication error",
165            ProviderErrorKind::Api => "API error",
166            ProviderErrorKind::RateLimit => "Rate limited",
167            ProviderErrorKind::Server => "Server error",
168            ProviderErrorKind::Timeout => "Request timed out",
169            ProviderErrorKind::Network => "Network error",
170            ProviderErrorKind::StreamInterrupted => "Stream interrupted",
171            ProviderErrorKind::Unknown => "Provider error",
172        };
173        write!(f, "{prefix}: {}", self.message)?;
174        let mut diagnostics = Vec::new();
175        if let Some(status) = self.http_status {
176            diagnostics.push(format!("status {status}"));
177        }
178        if let Some(code) = &self.code {
179            diagnostics.push(format!("code {code}"));
180        }
181        if let Some(request_id) = &self.request_id {
182            diagnostics.push(format!("request_id {request_id}"));
183        }
184        if !diagnostics.is_empty() {
185            write!(f, " ({})", diagnostics.join(", "))?;
186        }
187        Ok(())
188    }
189}
190
191impl std::error::Error for ProviderError {}
192
193impl LlmError {
194    pub fn is_retryable(&self) -> bool {
195        self.provider().is_some_and(ProviderError::is_retryable)
196    }
197
198    pub fn provider(&self) -> Option<&ProviderError> {
199        match self {
200            Self::Provider(error) => Some(error),
201            _ => None,
202        }
203    }
204}
205
206impl From<reqwest::Error> for LlmError {
207    fn from(error: reqwest::Error) -> Self {
208        if error.is_timeout() {
209            return ProviderError::timeout(error.to_string()).into();
210        }
211        if error.is_connect() || error.is_request() {
212            return ProviderError::network(error.to_string()).into();
213        }
214        match error.status().map(|s| s.as_u16()) {
215            Some(status) => ProviderError::from_http_status(status, error.to_string()).into(),
216            None => ProviderError::network(error.to_string()).into(),
217        }
218    }
219}
220
221impl From<serde_json::Error> for LlmError {
222    fn from(error: serde_json::Error) -> Self {
223        LlmError::JsonParsing(error.to_string())
224    }
225}
226
227impl From<reqwest::header::InvalidHeaderValue> for LlmError {
228    fn from(error: reqwest::header::InvalidHeaderValue) -> Self {
229        LlmError::ProviderRequest(error.to_string())
230    }
231}
232
233#[cfg(feature = "codex")]
234impl From<aether_auth::OAuthError> for LlmError {
235    fn from(error: aether_auth::OAuthError) -> Self {
236        LlmError::OAuthError(error.to_string())
237    }
238}
239
240pub type Result<T> = std::result::Result<T, LlmError>;
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn retryable_kinds_cover_transient_failures() {
248        assert!(!ProviderErrorKind::Authentication.is_retryable());
249        assert!(!ProviderErrorKind::Api.is_retryable());
250        assert!(ProviderErrorKind::RateLimit.is_retryable());
251        assert!(ProviderErrorKind::Server.is_retryable());
252        assert!(ProviderErrorKind::Timeout.is_retryable());
253        assert!(ProviderErrorKind::Network.is_retryable());
254        assert!(ProviderErrorKind::StreamInterrupted.is_retryable());
255        assert!(ProviderErrorKind::Unknown.is_retryable());
256    }
257
258    #[test]
259    fn http_status_classification_covers_known_statuses() {
260        assert_eq!(ProviderError::from_http_status(401, "d").kind, ProviderErrorKind::Authentication);
261        assert_eq!(ProviderError::from_http_status(403, "d").kind, ProviderErrorKind::Authentication);
262        assert_eq!(ProviderError::from_http_status(408, "d").kind, ProviderErrorKind::Timeout);
263        assert!(ProviderError::from_http_status(408, "d").is_retryable());
264        assert_eq!(ProviderError::from_http_status(429, "d").kind, ProviderErrorKind::RateLimit);
265        assert_eq!(ProviderError::from_http_status(500, "d").kind, ProviderErrorKind::Server);
266        assert_eq!(ProviderError::from_http_status(503, "d").kind, ProviderErrorKind::Server);
267        assert_eq!(ProviderError::from_http_status(400, "d").kind, ProviderErrorKind::Api);
268        assert!(!ProviderError::from_http_status(400, "d").is_retryable());
269        assert_eq!(ProviderError::from_http_status(404, "d").kind, ProviderErrorKind::Api);
270    }
271
272    #[test]
273    fn display_includes_diagnostics_when_present() {
274        let error = ProviderError::server("boom").with_http_status(200).with_code(Some("server_error".into()));
275        let text = error.to_string();
276        assert!(text.contains("boom"));
277        assert!(text.contains("status 200"));
278        assert!(text.contains("code server_error"));
279        let error = error.with_request_id(Some("req-1".into()));
280        assert!(error.to_string().contains("request_id req-1"));
281    }
282
283    #[test]
284    fn display_omits_suffix_when_no_metadata() {
285        let error = ProviderError::api("bad");
286        assert_eq!(error.to_string(), "API error: bad");
287    }
288
289    #[test]
290    fn is_retryable() {
291        assert!(LlmError::from(ProviderError::rate_limit("rl")).is_retryable());
292        assert!(LlmError::from(ProviderError::server("x").with_http_status(503)).is_retryable());
293        assert!(LlmError::from(ProviderError::server("stream-level")).is_retryable());
294        assert!(LlmError::from(ProviderError::timeout("t")).is_retryable());
295        assert!(LlmError::from(ProviderError::network("n")).is_retryable());
296        assert!(LlmError::from(ProviderError::stream_interrupted("s")).is_retryable());
297
298        assert!(!LlmError::from(ProviderError::api("x")).is_retryable());
299        assert!(!LlmError::from(ProviderError::authentication("x")).is_retryable());
300        assert!(!LlmError::MissingApiKey("x".into()).is_retryable());
301        assert!(!LlmError::JsonParsing("x".into()).is_retryable());
302        assert!(!LlmError::ToolParameterParsing { tool_name: "t".into(), error: "e".into() }.is_retryable());
303        assert!(!LlmError::OAuthError("x".into()).is_retryable());
304        assert!(!LlmError::UnsupportedContent("x".into()).is_retryable());
305        assert!(!LlmError::MissingProviderUrl { provider: "azure-foundry".into() }.is_retryable());
306        assert!(!LlmError::UnknownProvider { provider: "foo".into() }.is_retryable());
307        assert!(!LlmError::EmptyModelSpec.is_retryable());
308        assert!(
309            !LlmError::DuplicateProvider { provider: "bedrock".into(), field: "inferenceProfileArn".into() }
310                .is_retryable()
311        );
312        assert!(!LlmError::InvalidModelSpec("x".into()).is_retryable());
313        assert!(!LlmError::ProviderRequest("x".into()).is_retryable());
314        assert!(!LlmError::InvalidArgument("x".into()).is_retryable());
315    }
316}