Skip to main content

llmsdk_provider/
error.rs

1//! Unified error type for all provider operations.
2//!
3//! Mirrors `@ai-sdk/provider`'s `errors/*` directory. ai-sdk uses a class per
4//! error (`APICallError`, `InvalidPromptError`, ...); we collapse these into
5//! a single canonical [`ProviderError`] struct backed by a private
6//! [`ErrorKind`], following Microsoft's [M-ERRORS-CANONICAL-STRUCTS].
7//!
8//! Inspection is done via the `is_*()` helpers, never by matching `ErrorKind`
9//! directly (it is private to keep the API additive).
10// Rust guideline compliant 2026-02-21
11
12use std::backtrace::Backtrace;
13use std::collections::HashMap;
14use std::fmt;
15
16use serde_json::Value as JsonValue;
17
18/// Convenience alias used across the crate.
19pub type Result<T> = std::result::Result<T, ProviderError>;
20
21/// Boxed source error preserved as the [`std::error::Error::source`] chain.
22type Source = Box<dyn std::error::Error + Send + Sync + 'static>;
23
24/// Unified error returned by every provider operation.
25///
26/// Use the `is_*()` helpers to branch on cause. The underlying variant is
27/// intentionally hidden so we can add new failure modes without a breaking
28/// change.
29///
30/// Internally boxed so `Result<T, ProviderError>` stays one machine word
31/// in the `Err` slot.
32///
33/// # Examples
34///
35/// ```
36/// use llmsdk_provider::ProviderError;
37///
38/// let err = ProviderError::no_such_model("gpt-foo", "languageModel");
39/// assert!(err.is_no_such_model());
40/// assert_eq!(err.model_id(), Some("gpt-foo"));
41/// ```
42pub struct ProviderError {
43    inner: Box<ErrorInner>,
44}
45
46struct ErrorInner {
47    kind: ErrorKind,
48    backtrace: Backtrace,
49    source: Option<Source>,
50}
51
52/// Private enum carrying per-variant data.
53///
54/// Kept `pub(crate)` so provider crates can construct via the inherent
55/// helpers below, never by naming the variant. Adding a new variant is not
56/// a breaking change.
57#[derive(Debug)]
58#[expect(
59    dead_code,
60    reason = "value/text retained for Debug output and future accessors"
61)]
62pub(crate) enum ErrorKind {
63    ApiCall(ApiCallData),
64    InvalidArgument {
65        argument: String,
66        message: String,
67    },
68    InvalidPrompt {
69        message: String,
70    },
71    TypeValidation {
72        path: String,
73        value: JsonValue,
74        message: String,
75    },
76    JsonParse {
77        text: String,
78        message: String,
79    },
80    EmptyResponseBody,
81    NoContentGenerated,
82    NoSuchModel {
83        model_id: String,
84        model_type: String,
85    },
86    Unsupported {
87        functionality: String,
88    },
89    LoadApiKey {
90        message: String,
91    },
92    TooManyEmbeddingValues {
93        max: usize,
94        actual: usize,
95    },
96}
97
98/// Detail payload for an HTTP-level API call failure.
99#[derive(Debug, Default)]
100pub(crate) struct ApiCallData {
101    pub url: String,
102    pub message: String,
103    pub status_code: Option<u16>,
104    pub response_headers: Option<HashMap<String, String>>,
105    pub response_body: Option<String>,
106    pub request_body: Option<JsonValue>,
107    pub is_retryable: bool,
108}
109
110impl ProviderError {
111    // ---- constructors -------------------------------------------------
112
113    /// Build an HTTP API call error.
114    ///
115    /// `is_retryable` is `true` by default for 408 / 409 / 429 / 5xx,
116    /// matching ai-sdk's `APICallError` defaults. Use [`Self::api_call_builder`]
117    /// for full control.
118    pub fn api_call(url: impl Into<String>, message: impl Into<String>) -> Self {
119        Self::from_kind(ErrorKind::ApiCall(ApiCallData {
120            url: url.into(),
121            message: message.into(),
122            ..ApiCallData::default()
123        }))
124    }
125
126    /// Open a builder for an API call error with optional fields.
127    pub fn api_call_builder(
128        url: impl Into<String>,
129        message: impl Into<String>,
130    ) -> ApiCallErrorBuilder {
131        ApiCallErrorBuilder {
132            data: ApiCallData {
133                url: url.into(),
134                message: message.into(),
135                ..ApiCallData::default()
136            },
137            source: None,
138            retryable_override: None,
139        }
140    }
141
142    /// Build an invalid-argument error for a public parameter.
143    pub fn invalid_argument(argument: impl Into<String>, message: impl Into<String>) -> Self {
144        Self::from_kind(ErrorKind::InvalidArgument {
145            argument: argument.into(),
146            message: message.into(),
147        })
148    }
149
150    /// Build an invalid-prompt error.
151    pub fn invalid_prompt(message: impl Into<String>) -> Self {
152        Self::from_kind(ErrorKind::InvalidPrompt {
153            message: message.into(),
154        })
155    }
156
157    /// Build a type-validation error for a JSON path.
158    pub fn type_validation(
159        path: impl Into<String>,
160        value: JsonValue,
161        message: impl Into<String>,
162    ) -> Self {
163        Self::from_kind(ErrorKind::TypeValidation {
164            path: path.into(),
165            value,
166            message: message.into(),
167        })
168    }
169
170    /// Build a JSON parse error.
171    pub fn json_parse(text: impl Into<String>, message: impl Into<String>) -> Self {
172        Self::from_kind(ErrorKind::JsonParse {
173            text: text.into(),
174            message: message.into(),
175        })
176    }
177
178    /// The provider returned an empty body where content was expected.
179    #[must_use]
180    pub fn empty_response_body() -> Self {
181        Self::from_kind(ErrorKind::EmptyResponseBody)
182    }
183
184    /// The provider returned successfully but produced no usable content.
185    #[must_use]
186    pub fn no_content_generated() -> Self {
187        Self::from_kind(ErrorKind::NoContentGenerated)
188    }
189
190    /// No such model id for the given model type (`languageModel`, ...).
191    pub fn no_such_model(model_id: impl Into<String>, model_type: impl Into<String>) -> Self {
192        Self::from_kind(ErrorKind::NoSuchModel {
193            model_id: model_id.into(),
194            model_type: model_type.into(),
195        })
196    }
197
198    /// The provider does not support the requested functionality.
199    pub fn unsupported(functionality: impl Into<String>) -> Self {
200        Self::from_kind(ErrorKind::Unsupported {
201            functionality: functionality.into(),
202        })
203    }
204
205    /// Could not load an API key from the environment / config.
206    pub fn load_api_key(message: impl Into<String>) -> Self {
207        Self::from_kind(ErrorKind::LoadApiKey {
208            message: message.into(),
209        })
210    }
211
212    /// Too many embedding inputs passed in a single call.
213    #[must_use]
214    pub fn too_many_embedding_values(max: usize, actual: usize) -> Self {
215        Self::from_kind(ErrorKind::TooManyEmbeddingValues { max, actual })
216    }
217
218    // ---- inspection ---------------------------------------------------
219
220    /// True for API-level HTTP failures.
221    #[must_use]
222    pub fn is_api_call(&self) -> bool {
223        matches!(self.inner.kind, ErrorKind::ApiCall(_))
224    }
225
226    /// True when the failure should be retried.
227    ///
228    /// Currently only [`Self::is_api_call`] errors carry retry information;
229    /// all others return `false`.
230    #[must_use]
231    pub fn is_retryable(&self) -> bool {
232        matches!(&self.inner.kind, ErrorKind::ApiCall(d) if d.is_retryable)
233    }
234
235    /// True when the error reports an unknown model id.
236    #[must_use]
237    pub fn is_no_such_model(&self) -> bool {
238        matches!(self.inner.kind, ErrorKind::NoSuchModel { .. })
239    }
240
241    /// True when the error reports unsupported functionality.
242    #[must_use]
243    pub fn is_unsupported(&self) -> bool {
244        matches!(self.inner.kind, ErrorKind::Unsupported { .. })
245    }
246
247    /// HTTP status code when [`Self::is_api_call`].
248    #[must_use]
249    pub fn status_code(&self) -> Option<u16> {
250        match &self.inner.kind {
251            ErrorKind::ApiCall(d) => d.status_code,
252            _ => None,
253        }
254    }
255
256    /// Captured response body when [`Self::is_api_call`].
257    ///
258    /// Returned by HTTP transports that read the full body before raising
259    /// the error; otherwise `None`.
260    #[must_use]
261    pub fn response_body(&self) -> Option<&str> {
262        match &self.inner.kind {
263            ErrorKind::ApiCall(d) => d.response_body.as_deref(),
264            _ => None,
265        }
266    }
267
268    /// Request URL when [`Self::is_api_call`].
269    #[must_use]
270    pub fn url(&self) -> Option<&str> {
271        match &self.inner.kind {
272            ErrorKind::ApiCall(d) => Some(&d.url),
273            _ => None,
274        }
275    }
276
277    /// Model id when [`Self::is_no_such_model`].
278    #[must_use]
279    pub fn model_id(&self) -> Option<&str> {
280        match &self.inner.kind {
281            ErrorKind::NoSuchModel { model_id, .. } => Some(model_id),
282            _ => None,
283        }
284    }
285
286    /// Captured backtrace (empty unless `RUST_BACKTRACE` is set).
287    pub fn backtrace(&self) -> &Backtrace {
288        &self.inner.backtrace
289    }
290
291    // ---- internal -----------------------------------------------------
292
293    fn from_kind(kind: ErrorKind) -> Self {
294        Self {
295            inner: Box::new(ErrorInner {
296                kind,
297                backtrace: Backtrace::capture(),
298                source: None,
299            }),
300        }
301    }
302
303    pub(crate) fn with_source(mut self, source: Source) -> Self {
304        self.inner.source = Some(source);
305        self
306    }
307}
308
309/// Builder for API call errors with optional fields.
310///
311/// Returned by [`ProviderError::api_call_builder`]. Methods are chainable;
312/// call [`Self::build`] to finalize.
313#[derive(Debug)]
314pub struct ApiCallErrorBuilder {
315    data: ApiCallData,
316    source: Option<Source>,
317    retryable_override: Option<bool>,
318}
319
320impl ApiCallErrorBuilder {
321    /// Set the HTTP status code.
322    #[must_use]
323    pub fn status_code(mut self, code: u16) -> Self {
324        self.data.status_code = Some(code);
325        self
326    }
327
328    /// Set the response body.
329    #[must_use]
330    pub fn response_body(mut self, body: impl Into<String>) -> Self {
331        self.data.response_body = Some(body.into());
332        self
333    }
334
335    /// Set the response headers.
336    #[must_use]
337    pub fn response_headers(mut self, headers: HashMap<String, String>) -> Self {
338        self.data.response_headers = Some(headers);
339        self
340    }
341
342    /// Set the request body that was sent (for telemetry).
343    #[must_use]
344    pub fn request_body(mut self, body: JsonValue) -> Self {
345        self.data.request_body = Some(body);
346        self
347    }
348
349    /// Override the auto-derived retry flag.
350    ///
351    /// Without this call, retryable defaults to `true` for status 408 / 409 /
352    /// 429 / 5xx (matching `@ai-sdk/provider`).
353    #[must_use]
354    pub fn retryable(mut self, retryable: bool) -> Self {
355        self.retryable_override = Some(retryable);
356        self
357    }
358
359    /// Attach an upstream cause.
360    #[must_use]
361    pub fn source<E>(mut self, err: E) -> Self
362    where
363        E: std::error::Error + Send + Sync + 'static,
364    {
365        self.source = Some(Box::new(err));
366        self
367    }
368
369    /// Finalize the error.
370    #[must_use]
371    pub fn build(mut self) -> ProviderError {
372        // Matches @ai-sdk/provider's defaults: retry 408/409/429 + 5xx.
373        let derived = matches!(self.data.status_code, Some(408 | 409 | 429 | 500..));
374        self.data.is_retryable = self.retryable_override.unwrap_or(derived);
375        let err = ProviderError::from_kind(ErrorKind::ApiCall(self.data));
376        if let Some(src) = self.source {
377            err.with_source(src)
378        } else {
379            err
380        }
381    }
382}
383
384// ---- trait impls ------------------------------------------------------
385
386impl fmt::Debug for ProviderError {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        f.debug_struct("ProviderError")
389            .field("kind", &self.inner.kind)
390            .field("source", &self.inner.source)
391            .finish_non_exhaustive()
392    }
393}
394
395impl fmt::Display for ProviderError {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        match &self.inner.kind {
398            ErrorKind::ApiCall(d) => {
399                write!(f, "api call to {} failed: {}", d.url, d.message)?;
400                if let Some(code) = d.status_code {
401                    write!(f, " (status {code})")?;
402                }
403                Ok(())
404            }
405            ErrorKind::InvalidArgument { argument, message } => {
406                write!(f, "invalid argument `{argument}`: {message}")
407            }
408            ErrorKind::InvalidPrompt { message } => write!(f, "invalid prompt: {message}"),
409            ErrorKind::TypeValidation { path, message, .. } => {
410                write!(f, "type validation failed at `{path}`: {message}")
411            }
412            ErrorKind::JsonParse { message, .. } => write!(f, "json parse error: {message}"),
413            ErrorKind::EmptyResponseBody => f.write_str("empty response body"),
414            ErrorKind::NoContentGenerated => f.write_str("no content generated"),
415            ErrorKind::NoSuchModel {
416                model_id,
417                model_type,
418            } => {
419                write!(f, "no such {model_type}: `{model_id}`")
420            }
421            ErrorKind::Unsupported { functionality } => {
422                write!(f, "unsupported functionality: {functionality}")
423            }
424            ErrorKind::LoadApiKey { message } => write!(f, "could not load api key: {message}"),
425            ErrorKind::TooManyEmbeddingValues { max, actual } => {
426                write!(f, "too many embedding values: max {max}, got {actual}")
427            }
428        }
429    }
430}
431
432impl std::error::Error for ProviderError {
433    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
434        self.inner
435            .source
436            .as_deref()
437            .map(|e| e as &(dyn std::error::Error + 'static))
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn helpers_branch_correctly() {
447        let e = ProviderError::no_such_model("gpt-foo", "languageModel");
448        assert!(e.is_no_such_model());
449        assert_eq!(e.model_id(), Some("gpt-foo"));
450        assert!(!e.is_retryable());
451    }
452
453    #[test]
454    fn api_call_builder_auto_retryable() {
455        let e = ProviderError::api_call_builder("https://api.test", "boom")
456            .status_code(503)
457            .build();
458        assert!(e.is_api_call());
459        assert!(e.is_retryable());
460        assert_eq!(e.status_code(), Some(503));
461    }
462
463    #[test]
464    fn api_call_builder_explicit_non_retryable() {
465        let e = ProviderError::api_call_builder("https://api.test", "boom")
466            .status_code(500)
467            .retryable(false)
468            .build();
469        assert!(!e.is_retryable());
470    }
471
472    #[test]
473    fn display_format_stable() {
474        let e = ProviderError::invalid_argument("temperature", "must be >= 0");
475        assert_eq!(
476            format!("{e}"),
477            "invalid argument `temperature`: must be >= 0"
478        );
479    }
480}