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}