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}