Skip to main content

aimux_core/
error.rs

1//! Error types for aimux-core.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use ts_rs::TS;
6
7/// What a failed API call observed — the field set of the AI SDK's
8/// `APICallError` (`statusCode` / `responseBody`), which this project's error
9/// taxonomy mirrors (docs/internal/vercel-research/07-kernel-infrastructure.md).
10///
11/// Carried by [`AiMuxError::ApiCall`] alone. There are no per-status
12/// variants: a 401, 404 or 429 is an `ApiCall` error whose classification is
13/// read from `status_code`, exactly as the AI SDK reads
14/// `APICallError.statusCode`. Transport failures (connection reset, DNS,
15/// body read) are `ApiCall` errors too — no response arrived, so
16/// `status_code` is `None` and `is_retryable` is `true`, exactly as the AI
17/// SDK's `handleFetchError` builds an `APICallError` with no `statusCode`
18/// and `isRetryable: true`. `message` holds the provider's text **only** —
19/// the status is a field, never baked into the string; `Display` composes
20/// the human-readable form from the fields at print time, so nothing
21/// downstream has to parse it back out.
22///
23/// `APICallError` fields not carried in this round: `url` /
24/// `requestBodyValues` / `responseHeaders` (the request context is not
25/// available at the error-construction sites today; all fields are
26/// `#[serde(default)]`, so adding them later is not a breaking change).
27#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
28#[ts(export)]
29pub struct ApiCallError {
30    /// HTTP status of the response, when it came from one
31    /// (`APICallError.statusCode`). Always the *observed* status: the HTTP
32    /// layer fills it for every response-derived error; errors built without
33    /// an HTTP exchange leave it `None`.
34    #[serde(default)]
35    pub status_code: Option<u16>,
36    /// Provider's machine-readable code (OpenAI's `code`/`type`,
37    /// e.g. `"rate_limit_exceeded"` vs `"insufficient_quota"`). Never an HTTP
38    /// status. Our normalized take on `APICallError.data`.
39    #[serde(default)]
40    pub provider_code: Option<String>,
41    /// The provider's message, verbatim. No status prefix.
42    pub message: String,
43    /// The raw response body, verbatim (`APICallError.responseBody`) — the
44    /// lossless evidence when `message`/`provider_code` are extractions.
45    /// `None` when the error did not come from a response body.
46    #[serde(default)]
47    pub response_body: Option<String>,
48    /// Provider-assigned request id, from the `x-request-id` / `request-id`
49    /// response header when one was sent. Filled by the shared HTTP layer.
50    #[serde(default)]
51    pub request_id: Option<String>,
52    /// Retry hint in milliseconds, distilled from the `retry-after-ms` /
53    /// `retry-after` response headers on a 429. The classification lives in
54    /// `status_code`; this is the matching action input.
55    #[serde(default)]
56    #[ts(type = "number | null")]
57    pub retry_after_ms: Option<u64>,
58    /// Whether retrying can help (`APICallError.isRetryable`) — stored at
59    /// construction, exactly like the AI SDK: the response path computes it
60    /// from the status (408/409/429 or 5xx), and the transport path (no response, so
61    /// no status to compute from) sets it to `true` explicitly.
62    #[serde(default)]
63    pub is_retryable: bool,
64}
65
66impl std::fmt::Display for ApiCallError {
67    /// `HTTP {status}: {message}` when a status is known (just `HTTP {status}`
68    /// if the response had no body), else `{message}`. `provider_code` and
69    /// `response_body` are machine-readable only and stay out of the text.
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match (self.status_code, self.message.is_empty()) {
72            (Some(status), false) => write!(f, "HTTP {}: {}", status, self.message),
73            (Some(status), true) => write!(f, "HTTP {status}"),
74            (None, _) => f.write_str(&self.message),
75        }
76    }
77}
78
79/// Unified error type for all aimux operations.
80///
81/// Variants are cut by *what the caller does about it*, not by where the
82/// failure came from. `ApiCall` carries the [`ApiCallError`] detail inline
83/// (unboxed, like async-openai's `ApiError(ApiError)`); the size guard in
84/// `error_value_golden_test` keeps the enum under clippy's
85/// `result_large_err` threshold.
86///
87/// Constructed as a named struct literal everywhere
88/// (`ApiCallError { status_code: .., ..Default::default() }`), the same
89/// shape as the AI SDK's named-options constructor.
90#[derive(Debug, Clone, Serialize, Deserialize, TS, Error)]
91#[ts(export)]
92pub enum AiMuxError {
93    #[error("API call error: {0}")]
94    ApiCall(ApiCallError),
95
96    #[error("JSON parse error: {0}")]
97    JsonParse(String),
98
99    #[error("invalid response data: {0}")]
100    InvalidResponseData(String),
101
102    #[error("tool error: {0}")]
103    Tool(String),
104
105    #[error("invalid argument: {0}")]
106    InvalidArgument(String),
107
108    #[error("invalid prompt: {0}")]
109    InvalidPrompt(String),
110
111    /// The access token has expired (or was invalidated) and must be refreshed
112    /// by the caller. RFC-0018 subscription mode: the library maps a 401 from
113    /// the Codex subscription endpoint to this variant; the integrator
114    /// orchestrates `codex_refresh` + retry.
115    ///
116    /// This is *not* a status-code avatar: `Auth`-style 401s and token
117    /// expiry share the same status, so `status_code` alone cannot express
118    /// the difference — the variant carries the extra bit ("refresh helps").
119    #[error("token expired: {0}")]
120    TokenExpired(String),
121
122    #[error("unsupported functionality: {0}")]
123    UnsupportedFunctionality(String),
124
125    /// Registry-level "model id does not resolve" (the AI SDK's
126    /// `NoSuchModelError { modelId, modelType }`). Pre-HTTP: the upstream API
127    /// was never called, so there is no status.
128    #[error("no such model: {model_id}")]
129    NoSuchModel {
130        model_id: String,
131        /// What kind of model was requested (`"languageModel"`,
132        /// `"imageModel"`, …), the AI SDK's `modelType`.
133        #[serde(default)]
134        model_type: String,
135    },
136
137    /// Registry-level "provider name does not resolve" (the AI SDK's
138    /// `NoSuchProviderError`).
139    #[error("No such provider: {provider_id}")]
140    NoSuchProvider { provider_id: String },
141
142    #[error("request timed out: {0}")]
143    Timeout(String),
144
145    #[error("request aborted")]
146    Aborted,
147
148    #[error("{0}")]
149    Other(String),
150}
151
152/// Canonical serde classification: syntactically broken/truncated JSON is a
153/// parse failure; well-formed JSON that violates the expected shape is invalid
154/// response data. `Io` has no transport context here, so it stays `JsonParse`;
155/// call sites with a concrete transport error should preserve that instead.
156impl From<serde_json::Error> for AiMuxError {
157    fn from(e: serde_json::Error) -> Self {
158        use serde_json::error::Category;
159        match e.classify() {
160            Category::Data => AiMuxError::InvalidResponseData(e.to_string()),
161            Category::Syntax | Category::Eof | Category::Io => AiMuxError::JsonParse(e.to_string()),
162        }
163    }
164}
165
166impl AiMuxError {
167    /// Returns `true` if the error is transient and can be retried.
168    ///
169    /// Reads the stored [`ApiCallError::is_retryable`] field, filled at
170    /// construction (the AI SDK's `APICallError.isRetryable`): 408/409/429/5xx on the
171    /// response path, `true` on the transport path. `Timeout` is a spent
172    /// caller-side time budget — the AI SDK treats it as part of the abort
173    /// family (`isAbortError` matches `"TimeoutError"`) and does not retry
174    /// it; neither do we.
175    #[must_use]
176    pub fn is_retryable(&self) -> bool {
177        match self {
178            AiMuxError::ApiCall(d) => d.is_retryable,
179            _ => false,
180        }
181    }
182
183    /// Returns the retry-after delay hint (in milliseconds) carried by this
184    /// error, if any — distilled from the `retry-after-ms` / `retry-after`
185    /// response headers into [`ApiCallError::retry_after_ms`]. Returns `None`
186    /// for errors that don't advertise a delay, in which case the retry
187    /// strategy falls back to exponential backoff.
188    ///
189    /// Mirrors the header-consulting behaviour of
190    /// `retryWithExponentialBackoffRespectingRetryHeaders` in the TS SDK.
191    #[must_use]
192    pub fn retry_after_hint(&self) -> Option<i64> {
193        match self {
194            AiMuxError::ApiCall(d) => d.retry_after_ms.map(|ms| ms as i64),
195            _ => None,
196        }
197    }
198
199    /// Returns the HTTP status code carried by this error, if any.
200    ///
201    /// This is the *observed* status only — the field the HTTP layer filled
202    /// when a response actually arrived (`parse_provider_error`,
203    /// `send_with_retry_raw`). An error built without an HTTP exchange (a
204    /// missing API key, a bare constructor, a transport failure) reports
205    /// `None`: no status was seen, so none is invented.
206    ///
207    /// `TokenExpired` reports `Some(401)` by definition, not by fabrication:
208    /// its only producer is the codex subscription channel's observed-401
209    /// mapping (RFC-0018), which trades the carried fields for the "refresh
210    /// helps" bit — the status is part of the variant's contract.
211    #[must_use]
212    pub fn status_code(&self) -> Option<u16> {
213        match self {
214            AiMuxError::TokenExpired(_) => Some(401),
215            AiMuxError::ApiCall(d) => d.status_code,
216            _ => None,
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    /// A bare constructor saw no HTTP exchange, so it reports no status —
226    /// nothing is invented. Response-derived errors get theirs from the field
227    /// (filled by the HTTP layer).
228    #[test]
229    fn bare_constructors_report_no_status() {
230        assert_eq!(
231            AiMuxError::ApiCall(ApiCallError {
232                message: "no api key".into(),
233                ..Default::default()
234            })
235            .status_code(),
236            None
237        );
238        assert_eq!(
239            AiMuxError::ApiCall(ApiCallError {
240                message: "reset".into(),
241                is_retryable: true,
242                ..Default::default()
243            })
244            .status_code(),
245            None
246        );
247        assert_eq!(
248            AiMuxError::ApiCall(ApiCallError {
249                message: "boom".into(),
250                ..Default::default()
251            })
252            .status_code(),
253            None
254        );
255    }
256
257    /// `TokenExpired` is definitionally an observed 401 (RFC-0018); the
258    /// status is part of the variant's contract, not fabrication.
259    #[test]
260    fn token_expired_is_a_401_by_contract() {
261        assert_eq!(
262            AiMuxError::TokenExpired("expired".into()).status_code(),
263            Some(401)
264        );
265        assert!(!AiMuxError::TokenExpired("expired".into()).is_retryable());
266    }
267
268    /// H1: the status comes from the field, never from parsing the message.
269    #[test]
270    fn status_code_reads_the_field_not_the_message() {
271        assert_eq!(
272            AiMuxError::ApiCall(ApiCallError {
273                status_code: Some(403),
274                message: "forbidden".into(),
275                ..Default::default()
276            })
277            .status_code(),
278            Some(403)
279        );
280        // A message that merely *looks* like an HTTP prefix carries no status.
281        assert_eq!(
282            AiMuxError::ApiCall(ApiCallError {
283                message: "HTTP 403: forbidden".into(),
284                ..Default::default()
285            })
286            .status_code(),
287            None
288        );
289        assert_eq!(
290            AiMuxError::Timeout("HTTP 408: total timeout after 1000ms".into()).status_code(),
291            None
292        );
293        assert_eq!(AiMuxError::JsonParse("HTTP 400".into()).status_code(), None);
294        assert_eq!(AiMuxError::Other("HTTP 400".into()).status_code(), None);
295    }
296
297    /// A timeout is a spent caller-side time budget, not a transient fault —
298    /// the AI SDK files it in the abort family and does not retry it.
299    /// Transport failures (no response) are retryable via the stored field.
300    #[test]
301    fn timeout_is_not_retryable_transport_is() {
302        assert!(!AiMuxError::Timeout("total timeout".into()).is_retryable());
303        assert!(
304            AiMuxError::ApiCall(ApiCallError {
305                message: "connection reset".into(),
306                is_retryable: true,
307                ..Default::default()
308            })
309            .is_retryable()
310        );
311        assert!(!AiMuxError::JsonParse("parse".into()).is_retryable());
312    }
313
314    /// A 429 is retryable and carries its hint wherever it lives — the
315    /// classification is the `status_code` field, not a variant.
316    #[test]
317    fn rate_limit_is_read_from_the_field() {
318        let err = AiMuxError::ApiCall(ApiCallError {
319            status_code: Some(429),
320            message: "quota exceeded".into(),
321            retry_after_ms: Some(7),
322            is_retryable: true,
323            ..Default::default()
324        });
325        assert!(err.is_retryable());
326        assert_eq!(err.retry_after_hint(), Some(7));
327        // A bare ApiCall error (no stored verdict) is not retryable.
328        assert!(
329            !AiMuxError::ApiCall(ApiCallError {
330                message: "boom".into(),
331                ..Default::default()
332            })
333            .is_retryable()
334        );
335
336        let json = serde_json::to_string(&err).unwrap();
337        let back: AiMuxError = serde_json::from_str(&json).unwrap();
338        assert_eq!(back.retry_after_hint(), Some(7));
339        assert_eq!(back.status_code(), Some(429));
340    }
341}