Skip to main content

aimux_core/
error.rs

1//! Error types for aimux-core.
2
3use std::collections::HashMap;
4use std::sync::LazyLock;
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use ts_rs::TS;
10
11/// What a failed API call observed — the field set of the AI SDK's
12/// `APICallError` (`statusCode` / `responseBody`), which this project's error
13/// taxonomy mirrors (docs/internal/vercel-research/07-kernel-infrastructure.md).
14///
15/// Carried by [`AiMuxError::ApiCall`] alone. There are no per-status
16/// variants: a 401, 404 or 429 is an `ApiCall` error whose classification is
17/// read from `status_code`, exactly as the AI SDK reads
18/// `APICallError.statusCode`. Transport failures (connection reset, DNS,
19/// body read) are `ApiCall` errors too — no response arrived, so
20/// `status_code` is `None` and `is_retryable` is `true`, exactly as the AI
21/// SDK's `handleFetchError` builds an `APICallError` with no `statusCode`
22/// and `isRetryable: true`. `message` holds the provider's text verbatim when
23/// one is available; transport and parsing failures use a library message
24/// with the source detail appended. The HTTP status is always a field, never
25/// baked into the string; `Display` composes the human-readable form from the
26/// fields at print time, so nothing downstream has to parse it back out.
27///
28/// Every producer must provide the sanitized request URL and request values.
29/// This keeps transport and response failures self-contained and matches the
30/// AI SDK's `APICallError` contract.
31#[derive(Debug, Clone, Serialize, Deserialize, TS)]
32#[ts(export)]
33pub struct ApiCallError {
34    /// Sanitized request URL. Required for every API-derived failure.
35    pub url: String,
36    /// Sanitized request values used to create the API request.
37    pub request_body_values: serde_json::Value,
38    /// HTTP status of the response, when it came from one
39    /// (`APICallError.statusCode`). Always the *observed* status: the HTTP
40    /// layer fills it for every response-derived error; errors built without
41    /// an HTTP exchange leave it `None`.
42    #[serde(default)]
43    pub status_code: Option<u16>,
44    /// Provider's machine-readable code (OpenAI's `code`/`type`,
45    /// e.g. `"rate_limit_exceeded"` vs `"insufficient_quota"`). Never an HTTP
46    /// status. Our normalized take on `APICallError.data`.
47    #[serde(default)]
48    pub provider_code: Option<String>,
49    /// Human-readable failure text. Provider text is verbatim when available;
50    /// locally detected transport/parse failures include their source detail.
51    /// Never includes an HTTP status prefix.
52    pub message: String,
53    /// The raw response body, verbatim (`APICallError.responseBody`) — the
54    /// lossless evidence when `message`/`provider_code` are extractions.
55    /// `None` when the error did not come from a response body.
56    #[serde(default)]
57    pub response_body: Option<String>,
58    /// Sanitized response headers.
59    #[serde(default)]
60    pub response_headers: Option<HashMap<String, String>>,
61    /// Parsed provider error data.
62    #[serde(default)]
63    pub data: Option<serde_json::Value>,
64    /// Whether retrying can help (`APICallError.isRetryable`) — stored at
65    /// construction, exactly like the AI SDK: the response path computes it
66    /// from the status (408/409/429 or 5xx), and the transport path (no response, so
67    /// no status to compute from) sets it to `true` explicitly.
68    #[serde(default)]
69    pub is_retryable: bool,
70}
71
72impl ApiCallError {
73    /// Create an API failure with its required request context.
74    pub fn new(
75        message: impl Into<String>,
76        url: impl Into<String>,
77        request_body_values: serde_json::Value,
78    ) -> Self {
79        Self {
80            url: url.into(),
81            request_body_values,
82            status_code: None,
83            provider_code: None,
84            message: message.into(),
85            response_body: None,
86            response_headers: None,
87            data: None,
88            is_retryable: false,
89        }
90    }
91}
92
93/// Why the retry wrapper stopped retrying.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
95#[serde(rename_all = "camelCase")]
96#[ts(export)]
97pub enum RetryErrorReason {
98    /// Every permitted retry attempt failed with a retryable error.
99    MaxRetriesExceeded,
100    /// A later attempt produced a non-retryable error.
101    ErrorNotRetryable,
102}
103
104/// Complete error history for a retried model operation.
105#[derive(Debug, Clone, Serialize, Deserialize, TS)]
106#[ts(export)]
107pub struct RetryError {
108    pub reason: RetryErrorReason,
109    pub errors: Vec<AiMuxError>,
110}
111
112static EMPTY_RETRY_HISTORY_ERROR: LazyLock<AiMuxError> =
113    LazyLock::new(|| AiMuxError::Other("Retry failed without recorded errors".into()));
114
115impl RetryError {
116    /// The final attempt error, or a stable invalid-history error when a
117    /// deserialized or manually constructed value has no attempts.
118    #[must_use]
119    pub fn last_error(&self) -> &AiMuxError {
120        match self.errors.last() {
121            Some(error) => error,
122            None => &EMPTY_RETRY_HISTORY_ERROR,
123        }
124    }
125}
126
127impl std::fmt::Display for RetryError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let attempts = self.errors.len();
130        let Some(last_error) = self.errors.last() else {
131            return f.write_str("Retry failed without recorded errors");
132        };
133        match self.reason {
134            RetryErrorReason::MaxRetriesExceeded => {
135                write!(
136                    f,
137                    "Failed after {attempts} attempts. Last error: {last_error}"
138                )
139            }
140            RetryErrorReason::ErrorNotRetryable => write!(
141                f,
142                "Failed after {attempts} attempts with non-retryable error: '{last_error}'"
143            ),
144        }
145    }
146}
147
148impl std::fmt::Display for ApiCallError {
149    /// `HTTP {status}: {message}` when a status is known (just `HTTP {status}`
150    /// if the response had no body), else `{message}`. `provider_code` and
151    /// `response_body` are machine-readable only and stay out of the text.
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match (self.status_code, self.message.is_empty()) {
154            (Some(status), false) => write!(f, "HTTP {}: {}", status, self.message),
155            (Some(status), true) => write!(f, "HTTP {status}"),
156            (None, _) => f.write_str(&self.message),
157        }
158    }
159}
160
161/// Unified error type for all aimux operations.
162///
163/// Variants are cut by *what the caller does about it*, not by where the
164/// failure came from. `ApiCallError` is boxed only to keep the Rust enum
165/// compact; serde and every binding still observe the same object shape.
166#[derive(Debug, Clone, Serialize, Deserialize, TS, Error)]
167#[ts(export)]
168pub enum AiMuxError {
169    #[error("API call error: {0}")]
170    ApiCall(Box<ApiCallError>),
171
172    #[error("{0}")]
173    Retry(RetryError),
174
175    #[error("JSON parse error: {0}")]
176    JsonParse(String),
177
178    #[error("invalid response data: {0}")]
179    InvalidResponseData(String),
180
181    /// A model requested a tool that was not present in the call's tool set.
182    /// Message templates for the three tool variants match the AI SDK verbatim
183    /// (`NoSuchToolError` / `InvalidToolInputError` / `ToolCallRepairError`):
184    /// the AI SDK feeds these strings back to the model as tool-error content,
185    /// so the wording — including the available-tools list — is contract.
186    #[error("Model tried to call unavailable tool '{tool_name}'. {}", no_such_tool_availability(.available_tools))]
187    NoSuchTool {
188        tool_name: String,
189        #[serde(default, skip_serializing_if = "Option::is_none")]
190        available_tools: Option<Vec<String>>,
191        /// Original argument text, when supplied by the provider. Absent in
192        /// older serialized errors and errors constructed without a call.
193        #[serde(default, skip_serializing_if = "Option::is_none")]
194        tool_input: Option<String>,
195    },
196
197    /// A tool call could not be parsed or did not satisfy its input schema.
198    #[error("Invalid input for tool {tool_name}: {cause}")]
199    InvalidToolInput {
200        tool_name: String,
201        tool_input: String,
202        cause: String,
203    },
204
205    /// The repair callback failed or returned a call that was still invalid.
206    #[error("Error repairing tool call: {cause}")]
207    ToolCallRepair {
208        original_error: Box<AiMuxError>,
209        cause: Box<AiMuxError>,
210    },
211
212    #[error("invalid argument: {0}")]
213    InvalidArgument(String),
214
215    #[error("invalid prompt: {0}")]
216    InvalidPrompt(String),
217
218    /// The access token has expired (or was invalidated) and must be refreshed
219    /// by the caller. RFC-0018 subscription mode: the library maps a 401 from
220    /// the Codex subscription endpoint to this variant; the integrator
221    /// orchestrates `codex_refresh` + retry.
222    ///
223    /// This is *not* a status-code avatar: `Auth`-style 401s and token
224    /// expiry share the same status, so `status_code` alone cannot express
225    /// the difference — the variant carries the extra bit ("refresh helps").
226    #[error("token expired: {0}")]
227    TokenExpired(String),
228
229    #[error("unsupported functionality: {0}")]
230    UnsupportedFunctionality(String),
231
232    /// Registry-level "model id does not resolve" (the AI SDK's
233    /// `NoSuchModelError { modelId, modelType }`). Pre-HTTP: the upstream API
234    /// was never called, so there is no status.
235    #[error("no such model: {model_id}")]
236    NoSuchModel {
237        model_id: String,
238        /// What kind of model was requested (`"languageModel"`,
239        /// `"imageModel"`, …), the AI SDK's `modelType`.
240        #[serde(default)]
241        model_type: String,
242    },
243
244    /// Registry-level "provider name does not resolve" (the AI SDK's
245    /// `NoSuchProviderError`).
246    #[error("No such provider: {provider_id}")]
247    NoSuchProvider { provider_id: String },
248
249    #[error("request timed out: {0}")]
250    Timeout(String),
251
252    #[error("{0}")]
253    Aborted(String),
254
255    #[error("{0}")]
256    Other(String),
257}
258
259fn no_such_tool_availability(available_tools: &Option<Vec<String>>) -> String {
260    match available_tools {
261        Some(tools) => format!("Available tools: {}.", tools.join(", ")),
262        None => "No tools are available.".to_string(),
263    }
264}
265
266/// Canonical serde classification: syntactically broken/truncated JSON is a
267/// parse failure; well-formed JSON that violates the expected shape is invalid
268/// response data. `Io` has no transport context here, so it stays `JsonParse`;
269/// call sites with a concrete transport error should preserve that instead.
270impl From<serde_json::Error> for AiMuxError {
271    fn from(e: serde_json::Error) -> Self {
272        use serde_json::error::Category;
273        match e.classify() {
274            Category::Data => AiMuxError::InvalidResponseData(e.to_string()),
275            Category::Syntax | Category::Eof | Category::Io => AiMuxError::JsonParse(e.to_string()),
276        }
277    }
278}
279
280/// The AI SDK's status-based retryability rule: 408 (timeout), 409
281/// (conflict), 429 (rate limit) and every 5xx are worth retrying; everything
282/// else is not. One definition — response handlers and providers must not
283/// hand-roll this formula.
284#[must_use]
285pub fn is_retryable_status(status: u16) -> bool {
286    matches!(status, 408 | 409 | 429) || status >= 500
287}
288
289impl AiMuxError {
290    /// Returns `true` for a malformed individual stream frame that does not
291    /// prove the underlying transport has failed. Stream reducers may report
292    /// these errors and continue polling for later, independently framed data.
293    #[must_use]
294    pub fn is_recoverable_stream_error(&self) -> bool {
295        matches!(self, Self::JsonParse(_) | Self::InvalidResponseData(_))
296    }
297
298    /// Convert caller cancellation into the public error model.
299    #[must_use]
300    pub fn from_abort_signal(_signal: &crate::AbortSignal) -> Self {
301        Self::Aborted("request aborted".into())
302    }
303
304    /// Returns `true` if the error is transient and can be retried.
305    ///
306    /// Reads the stored [`ApiCallError::is_retryable`] field, filled at
307    /// construction (the AI SDK's `APICallError.isRetryable`): 408/409/429/5xx on the
308    /// response path, `true` on the transport path. `Timeout` is a spent
309    /// caller-side time budget — the AI SDK treats it as part of the abort
310    /// family (`isAbortError` matches `"TimeoutError"`) and does not retry
311    /// it; neither do we.
312    #[must_use]
313    pub fn is_retryable(&self) -> bool {
314        match self {
315            AiMuxError::ApiCall(d) => d.is_retryable,
316            _ => false,
317        }
318    }
319
320    /// Returns the retry-after delay hint (in milliseconds) carried by this
321    /// error's response headers, if any.
322    ///
323    /// Mirrors the header-consulting behaviour of
324    /// `retryWithExponentialBackoffRespectingRetryHeaders` in the TS SDK.
325    #[must_use]
326    pub fn retry_after_hint(&self) -> Option<i64> {
327        let AiMuxError::ApiCall(detail) = self else {
328            return None;
329        };
330        let headers = detail.response_headers.as_ref()?;
331        headers
332            .get("retry-after-ms")
333            .and_then(|value| value.parse::<f64>().ok())
334            .filter(|value| !value.is_nan())
335            .map(|value| value as i64)
336            .or_else(|| {
337                headers.get("retry-after").and_then(|value| {
338                    value
339                        .parse::<f64>()
340                        .ok()
341                        .filter(|value| !value.is_nan())
342                        .map(|seconds| (seconds * 1_000.0) as i64)
343                        .or_else(|| {
344                            httpdate::parse_http_date(value).ok().map(|date| {
345                                match date.duration_since(SystemTime::now()) {
346                                    Ok(duration) => duration.as_millis() as i64,
347                                    Err(error) => -(error.duration().as_millis() as i64),
348                                }
349                            })
350                        })
351                })
352            })
353            // Upstream's `0 <= ms` check: a hint in the past is no hint. It
354            // also keeps the value clear of the C ABI's -1 = absent sentinel
355            // (`aimux_error_retry_ms`).
356            .filter(|milliseconds| *milliseconds >= 0)
357    }
358
359    /// Returns the HTTP status code carried by this error, if any.
360    ///
361    /// This is the *observed* status only — the field the selected response
362    /// handler filled when a response actually arrived. An error built without an HTTP exchange (a
363    /// missing API key, a bare constructor, a transport failure) reports
364    /// `None`: no status was seen, so none is invented.
365    ///
366    /// `TokenExpired` reports `Some(401)` by definition, not by fabrication:
367    /// its only producer is the codex subscription channel's observed-401
368    /// mapping (RFC-0018), which trades the carried fields for the "refresh
369    /// helps" bit — the status is part of the variant's contract.
370    #[must_use]
371    pub fn status_code(&self) -> Option<u16> {
372        match self {
373            AiMuxError::TokenExpired(_) => Some(401),
374            AiMuxError::ApiCall(d) => d.status_code,
375            _ => None,
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn api_error(message: &str) -> ApiCallError {
385        ApiCallError::new(message, "https://example.test", serde_json::json!({}))
386    }
387
388    /// A bare constructor saw no HTTP exchange, so it reports no status —
389    /// nothing is invented. Response-derived errors get theirs from the field
390    /// (filled by the HTTP layer).
391    #[test]
392    fn bare_constructors_report_no_status() {
393        assert_eq!(
394            AiMuxError::ApiCall(Box::new(api_error("no api key"))).status_code(),
395            None
396        );
397        assert_eq!(
398            AiMuxError::ApiCall(Box::new(ApiCallError {
399                is_retryable: true,
400                ..api_error("reset")
401            }))
402            .status_code(),
403            None
404        );
405        assert_eq!(
406            AiMuxError::ApiCall(Box::new(api_error("boom"))).status_code(),
407            None
408        );
409    }
410
411    /// `TokenExpired` is definitionally an observed 401 (RFC-0018); the
412    /// status is part of the variant's contract, not fabrication.
413    #[test]
414    fn token_expired_is_a_401_by_contract() {
415        assert_eq!(
416            AiMuxError::TokenExpired("expired".into()).status_code(),
417            Some(401)
418        );
419        assert!(!AiMuxError::TokenExpired("expired".into()).is_retryable());
420    }
421
422    /// H1: the status comes from the field, never from parsing the message.
423    #[test]
424    fn status_code_reads_the_field_not_the_message() {
425        assert_eq!(
426            AiMuxError::ApiCall(Box::new(ApiCallError {
427                status_code: Some(403),
428                ..api_error("forbidden")
429            }))
430            .status_code(),
431            Some(403)
432        );
433        // A message that merely *looks* like an HTTP prefix carries no status.
434        assert_eq!(
435            AiMuxError::ApiCall(Box::new(api_error("HTTP 403: forbidden"))).status_code(),
436            None
437        );
438        assert_eq!(
439            AiMuxError::Timeout("HTTP 408: total timeout after 1000ms".into()).status_code(),
440            None
441        );
442        assert_eq!(AiMuxError::JsonParse("HTTP 400".into()).status_code(), None);
443        assert_eq!(AiMuxError::Other("HTTP 400".into()).status_code(), None);
444    }
445
446    /// A timeout is a spent caller-side time budget, not a transient fault —
447    /// the AI SDK files it in the abort family and does not retry it.
448    /// Transport failures (no response) are retryable via the stored field.
449    #[test]
450    fn timeout_is_not_retryable_transport_is() {
451        assert!(!AiMuxError::Timeout("total timeout".into()).is_retryable());
452        assert!(
453            AiMuxError::ApiCall(Box::new(ApiCallError {
454                is_retryable: true,
455                ..api_error("connection reset")
456            }))
457            .is_retryable()
458        );
459        assert!(!AiMuxError::JsonParse("parse".into()).is_retryable());
460    }
461
462    #[test]
463    fn empty_retry_history_round_trips_without_panicking() {
464        let json = r#"{"reason":"maxRetriesExceeded","errors":[]}"#;
465        let error: RetryError = serde_json::from_str(json).unwrap();
466
467        assert!(error.errors.is_empty());
468        assert_eq!(error.to_string(), "Retry failed without recorded errors");
469        assert_eq!(
470            error.last_error().to_string(),
471            "Retry failed without recorded errors"
472        );
473        assert_eq!(serde_json::to_string(&error).unwrap(), json);
474    }
475
476    /// A 429 is retryable and carries its hint wherever it lives — the
477    /// classification is the `status_code` field, not a variant.
478    #[test]
479    fn rate_limit_is_read_from_the_field() {
480        let err = AiMuxError::ApiCall(Box::new(ApiCallError {
481            status_code: Some(429),
482            response_headers: Some(HashMap::from([("retry-after-ms".into(), "7".into())])),
483            is_retryable: true,
484            ..api_error("quota exceeded")
485        }));
486        assert!(err.is_retryable());
487        assert_eq!(err.retry_after_hint(), Some(7));
488        // A bare ApiCall error (no stored verdict) is not retryable.
489        assert!(!AiMuxError::ApiCall(Box::new(api_error("boom"))).is_retryable());
490
491        let json = serde_json::to_string(&err).unwrap();
492        let back: AiMuxError = serde_json::from_str(&json).unwrap();
493        assert_eq!(back.retry_after_hint(), Some(7));
494        assert_eq!(back.status_code(), Some(429));
495    }
496}