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    /// HTTP client creation failed
16    #[error("Failed to create HTTP client: {0}")]
17    HttpClientCreation(String),
18    /// Normalized provider-side failure carrying retry classification and
19    /// support diagnostics (HTTP status, request ID, provider error code).
20    #[error("{0}")]
21    Provider(#[from] ProviderError),
22    /// IO error while reading stream
23    #[error("IO error reading stream: {0}")]
24    IoError(String),
25    /// JSON parsing/serialization error
26    #[error("JSON parsing error: {0}")]
27    JsonParsing(String),
28    /// Tool parameter parsing error
29    #[error("Failed to parse tool parameters for {tool_name}: {error}")]
30    ToolParameterParsing { tool_name: String, error: String },
31    /// OAuth authentication error
32    #[error("OAuth error: {0}")]
33    OAuthError(String),
34    /// The message contained only content types this provider doesn't support
35    #[error("Unsupported content: {0}")]
36    UnsupportedContent(String),
37    /// Provider endpoint URL has not been configured.
38    #[error("Provider '{provider}' requires a URL configured via providers.{provider}.url")]
39    MissingProviderUrl { provider: String },
40    /// Provider name is not registered with the model parser.
41    #[error("Unknown provider: {provider}")]
42    UnknownProvider { provider: String },
43    /// The model spec did not yield any usable provider.
44    #[error("No models provided")]
45    EmptyModelSpec,
46    /// A single-model-only config field was reused across multiple models of the
47    /// same provider within one alloy spec (e.g. bedrock `inferenceProfileArn`
48    /// or openai-compatible `requestModel`).
49    #[error("providers.{provider}.{field} cannot be used with multiple {provider} models in one alloy spec")]
50    DuplicateProvider { provider: String, field: String },
51    /// A `provider:model` identity could not be parsed.
52    #[error("Invalid model spec: {0}")]
53    InvalidModelSpec(String),
54    /// A provider request body could not be constructed (e.g. an SDK builder
55    /// rejected the input or contained malformed data).
56    #[error("Failed to build provider request: {0}")]
57    ProviderRequest(String),
58    /// An upstream client library rejected an argument as invalid.
59    #[error("Invalid argument: {0}")]
60    InvalidArgument(String),
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ProviderErrorKind {
65    Authentication,
66    Api,
67    RateLimit,
68    Server,
69    Timeout,
70    Network,
71    StreamInterrupted,
72    Unknown,
73}
74
75impl ProviderErrorKind {
76    pub fn is_retryable(&self) -> bool {
77        matches!(
78            self,
79            Self::RateLimit | Self::Server | Self::Timeout | Self::Network | Self::StreamInterrupted | Self::Unknown
80        )
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ProviderError {
86    pub kind: ProviderErrorKind,
87    pub message: String,
88    pub http_status: Option<u16>,
89    pub request_id: Option<String>,
90    pub code: Option<String>,
91}
92
93impl ProviderError {
94    pub fn new(kind: ProviderErrorKind, message: impl Into<String>) -> Self {
95        Self { kind, message: message.into(), http_status: None, request_id: None, code: None }
96    }
97
98    pub fn authentication(message: impl Into<String>) -> Self {
99        Self::new(ProviderErrorKind::Authentication, message)
100    }
101
102    pub fn api(message: impl Into<String>) -> Self {
103        Self::new(ProviderErrorKind::Api, message)
104    }
105
106    pub fn rate_limit(message: impl Into<String>) -> Self {
107        Self::new(ProviderErrorKind::RateLimit, message)
108    }
109
110    pub fn server(message: impl Into<String>) -> Self {
111        Self::new(ProviderErrorKind::Server, message)
112    }
113
114    pub fn timeout(message: impl Into<String>) -> Self {
115        Self::new(ProviderErrorKind::Timeout, message)
116    }
117
118    pub fn network(message: impl Into<String>) -> Self {
119        Self::new(ProviderErrorKind::Network, message)
120    }
121
122    pub fn stream_interrupted(message: impl Into<String>) -> Self {
123        Self::new(ProviderErrorKind::StreamInterrupted, message)
124    }
125
126    pub fn from_http_status(status: u16, message: impl Into<String>) -> Self {
127        match status {
128            401 | 403 => Self::authentication(message),
129            408 | 504 => Self::timeout(message),
130            429 => Self::rate_limit(message),
131            s if (500..600).contains(&s) => Self::server(message),
132            _ => Self::api(message),
133        }
134        .with_http_status(status)
135    }
136
137    pub fn with_http_status(mut self, status: u16) -> Self {
138        self.http_status = Some(status);
139        self
140    }
141
142    pub fn with_http_metadata(mut self, status: Option<u16>, request_id: Option<String>) -> Self {
143        self.http_status = status;
144        self.request_id = request_id;
145        self
146    }
147
148    pub fn with_code(mut self, code: Option<String>) -> Self {
149        self.code = code;
150        self
151    }
152
153    pub fn with_request_id(mut self, request_id: Option<String>) -> Self {
154        self.request_id = request_id;
155        self
156    }
157
158    pub fn is_retryable(&self) -> bool {
159        self.kind.is_retryable()
160    }
161}
162
163impl fmt::Display for ProviderError {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        let prefix = match self.kind {
166            ProviderErrorKind::Authentication => "Authentication error",
167            ProviderErrorKind::Api => "API error",
168            ProviderErrorKind::RateLimit => "Rate limited",
169            ProviderErrorKind::Server => "Server error",
170            ProviderErrorKind::Timeout => "Request timed out",
171            ProviderErrorKind::Network => "Network error",
172            ProviderErrorKind::StreamInterrupted => "Stream interrupted",
173            ProviderErrorKind::Unknown => "Provider error",
174        };
175        write!(f, "{prefix}: {}", self.message)?;
176        let mut diagnostics = Vec::new();
177        if let Some(status) = self.http_status {
178            diagnostics.push(format!("status {status}"));
179        }
180        if let Some(code) = &self.code {
181            diagnostics.push(format!("code {code}"));
182        }
183        if let Some(request_id) = &self.request_id {
184            diagnostics.push(format!("request_id {request_id}"));
185        }
186        if !diagnostics.is_empty() {
187            write!(f, " ({})", diagnostics.join(", "))?;
188        }
189        Ok(())
190    }
191}
192
193impl std::error::Error for ProviderError {}
194
195impl LlmError {
196    pub fn is_retryable(&self) -> bool {
197        self.provider().is_some_and(ProviderError::is_retryable)
198    }
199
200    pub fn provider(&self) -> Option<&ProviderError> {
201        match self {
202            Self::Provider(error) => Some(error),
203            _ => None,
204        }
205    }
206}
207
208impl From<reqwest::Error> for LlmError {
209    fn from(error: reqwest::Error) -> Self {
210        if error.is_timeout() {
211            return ProviderError::timeout(error.to_string()).into();
212        }
213        if error.is_connect() || error.is_request() {
214            return ProviderError::network(error.to_string()).into();
215        }
216        match error.status().map(|s| s.as_u16()) {
217            Some(status) => ProviderError::from_http_status(status, error.to_string()).into(),
218            None => ProviderError::network(error.to_string()).into(),
219        }
220    }
221}
222
223impl From<serde_json::Error> for LlmError {
224    fn from(error: serde_json::Error) -> Self {
225        LlmError::JsonParsing(error.to_string())
226    }
227}
228
229impl From<std::io::Error> for LlmError {
230    fn from(error: std::io::Error) -> Self {
231        LlmError::IoError(error.to_string())
232    }
233}
234
235impl From<reqwest::header::InvalidHeaderValue> for LlmError {
236    fn from(error: reqwest::header::InvalidHeaderValue) -> Self {
237        LlmError::ProviderRequest(error.to_string())
238    }
239}
240
241impl From<async_openai::error::OpenAIError> for LlmError {
242    fn from(error: async_openai::error::OpenAIError) -> Self {
243        use async_openai::error::OpenAIError;
244        match error {
245            OpenAIError::Reqwest(e) => LlmError::from(e),
246            OpenAIError::StreamError(e) => ProviderError::stream_interrupted(e.to_string()).into(),
247            OpenAIError::ApiError(api_err) => {
248                let status = api_err.status_code.as_u16();
249                let code = api_err.api_error.code.clone();
250                let message = format!("{status} {}", api_err.api_error);
251                ProviderError::from_http_status(status, message).with_code(code).into()
252            }
253            OpenAIError::JSONDeserialize(e, _) => LlmError::JsonParsing(e.to_string()),
254            OpenAIError::Boxed(e) => e
255                .downcast::<ProviderError>()
256                .map_or_else(|error| ProviderError::api(error.to_string()).into(), |error| (*error).into()),
257            OpenAIError::FileSaveError(s) | OpenAIError::FileReadError(s) => LlmError::IoError(s),
258            OpenAIError::InvalidArgument(s) => LlmError::InvalidArgument(s),
259        }
260    }
261}
262
263#[cfg(feature = "codex")]
264impl From<aether_auth::OAuthError> for LlmError {
265    fn from(error: aether_auth::OAuthError) -> Self {
266        LlmError::OAuthError(error.to_string())
267    }
268}
269
270pub type Result<T> = std::result::Result<T, LlmError>;
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn retryable_kinds_cover_transient_failures() {
278        assert!(!ProviderErrorKind::Authentication.is_retryable());
279        assert!(!ProviderErrorKind::Api.is_retryable());
280        assert!(ProviderErrorKind::RateLimit.is_retryable());
281        assert!(ProviderErrorKind::Server.is_retryable());
282        assert!(ProviderErrorKind::Timeout.is_retryable());
283        assert!(ProviderErrorKind::Network.is_retryable());
284        assert!(ProviderErrorKind::StreamInterrupted.is_retryable());
285        assert!(ProviderErrorKind::Unknown.is_retryable());
286    }
287
288    #[test]
289    fn http_status_classification_covers_known_statuses() {
290        assert_eq!(ProviderError::from_http_status(401, "d").kind, ProviderErrorKind::Authentication);
291        assert_eq!(ProviderError::from_http_status(403, "d").kind, ProviderErrorKind::Authentication);
292        assert_eq!(ProviderError::from_http_status(408, "d").kind, ProviderErrorKind::Timeout);
293        assert!(ProviderError::from_http_status(408, "d").is_retryable());
294        assert_eq!(ProviderError::from_http_status(429, "d").kind, ProviderErrorKind::RateLimit);
295        assert_eq!(ProviderError::from_http_status(500, "d").kind, ProviderErrorKind::Server);
296        assert_eq!(ProviderError::from_http_status(503, "d").kind, ProviderErrorKind::Server);
297        assert_eq!(ProviderError::from_http_status(400, "d").kind, ProviderErrorKind::Api);
298        assert!(!ProviderError::from_http_status(400, "d").is_retryable());
299        assert_eq!(ProviderError::from_http_status(404, "d").kind, ProviderErrorKind::Api);
300    }
301
302    #[test]
303    fn display_includes_diagnostics_when_present() {
304        let error = ProviderError::server("boom").with_http_status(200).with_code(Some("server_error".into()));
305        let text = error.to_string();
306        assert!(text.contains("boom"));
307        assert!(text.contains("status 200"));
308        assert!(text.contains("code server_error"));
309        let error = error.with_request_id(Some("req-1".into()));
310        assert!(error.to_string().contains("request_id req-1"));
311    }
312
313    #[test]
314    fn display_omits_suffix_when_no_metadata() {
315        let error = ProviderError::api("bad");
316        assert_eq!(error.to_string(), "API error: bad");
317    }
318
319    #[test]
320    fn is_retryable() {
321        assert!(LlmError::from(ProviderError::rate_limit("rl")).is_retryable());
322        assert!(LlmError::from(ProviderError::server("x").with_http_status(503)).is_retryable());
323        assert!(LlmError::from(ProviderError::server("stream-level")).is_retryable());
324        assert!(LlmError::from(ProviderError::timeout("t")).is_retryable());
325        assert!(LlmError::from(ProviderError::network("n")).is_retryable());
326        assert!(LlmError::from(ProviderError::stream_interrupted("s")).is_retryable());
327
328        assert!(!LlmError::from(ProviderError::api("x")).is_retryable());
329        assert!(!LlmError::from(ProviderError::authentication("x")).is_retryable());
330        assert!(!LlmError::MissingApiKey("x".into()).is_retryable());
331        assert!(!LlmError::HttpClientCreation("x".into()).is_retryable());
332        assert!(!LlmError::IoError("x".into()).is_retryable());
333        assert!(!LlmError::JsonParsing("x".into()).is_retryable());
334        assert!(!LlmError::ToolParameterParsing { tool_name: "t".into(), error: "e".into() }.is_retryable());
335        assert!(!LlmError::OAuthError("x".into()).is_retryable());
336        assert!(!LlmError::UnsupportedContent("x".into()).is_retryable());
337        assert!(!LlmError::MissingProviderUrl { provider: "azure-foundry".into() }.is_retryable());
338        assert!(!LlmError::UnknownProvider { provider: "foo".into() }.is_retryable());
339        assert!(!LlmError::EmptyModelSpec.is_retryable());
340        assert!(
341            !LlmError::DuplicateProvider { provider: "bedrock".into(), field: "inferenceProfileArn".into() }
342                .is_retryable()
343        );
344        assert!(!LlmError::InvalidModelSpec("x".into()).is_retryable());
345        assert!(!LlmError::ProviderRequest("x".into()).is_retryable());
346        assert!(!LlmError::InvalidArgument("x".into()).is_retryable());
347    }
348
349    #[test]
350    fn async_openai_api_error_preserves_status_and_code() {
351        use async_openai::error::{ApiError, ApiErrorResponse};
352        let response = ApiErrorResponse {
353            status_code: reqwest::StatusCode::SERVICE_UNAVAILABLE,
354            api_error: ApiError {
355                message: "overloaded".to_string(),
356                r#type: None,
357                param: None,
358                code: Some("server_error".to_string()),
359            },
360        };
361        let error = LlmError::from(async_openai::error::OpenAIError::ApiError(response));
362        let provider = error.provider().expect("expected provider error");
363        assert_eq!(provider.kind, ProviderErrorKind::Server);
364        assert_eq!(provider.http_status, Some(503));
365        assert_eq!(provider.code.as_deref(), Some("server_error"));
366        assert!(error.is_retryable());
367    }
368
369    #[test]
370    fn async_openai_boxed_provider_error_preserves_classification_and_diagnostics() {
371        let provider = ProviderError::rate_limit("slow down")
372            .with_http_status(429)
373            .with_code(Some("429".into()))
374            .with_request_id(Some("request-123".into()));
375        let error = LlmError::from(async_openai::error::OpenAIError::Boxed(Box::new(provider.clone())));
376
377        assert_eq!(error.provider(), Some(&provider));
378        assert!(error.is_retryable());
379    }
380
381    #[test]
382    fn async_openai_stream_error_is_interruption() {
383        let io = std::io::Error::other("eof");
384        let error = LlmError::from(async_openai::error::OpenAIError::StreamError(Box::new(
385            async_openai::error::StreamError::EventStream(io.to_string()),
386        )));
387        let provider = error.provider().expect("expected provider error");
388        assert_eq!(provider.kind, ProviderErrorKind::StreamInterrupted);
389        assert!(error.is_retryable());
390    }
391
392    #[test]
393    fn async_openai_non_error_body_stays_a_json_parsing_error() {
394        let body = r#"{"id":"chatcmpl-1","object":"chat.completion","choices":[]}"#;
395        let parse_error = serde_json::from_str::<String>(body).unwrap_err();
396
397        let error = LlmError::from(async_openai::error::OpenAIError::JSONDeserialize(parse_error, body.to_string()));
398
399        assert!(matches!(error, LlmError::JsonParsing(_)), "got {error:?}");
400        assert!(!error.is_retryable());
401    }
402}