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/// Unified error type for all aimux operations.
8#[derive(Debug, Clone, Serialize, Deserialize, TS, Error)]
9#[ts(export)]
10pub enum AiMuxError {
11    #[error("provider error: {0}")]
12    Provider(String),
13
14    #[error("HTTP error: {0}")]
15    Http(String),
16
17    #[error("JSON error: {0}")]
18    Json(String),
19
20    #[error("stream error: {0}")]
21    Stream(String),
22
23    #[error("tool error: {0}")]
24    Tool(String),
25
26    #[error("invalid argument: {0}")]
27    InvalidArgument(String),
28
29    #[error("invalid prompt: {0}")]
30    InvalidPrompt(String),
31
32    #[error("rate limited: retry after {retry_after_ms}ms")]
33    RateLimited { retry_after_ms: u64 },
34
35    #[error("authentication failed: {0}")]
36    Auth(String),
37
38    /// The access token has expired (or was invalidated) and must be refreshed
39    /// by the caller. RFC-0018 subscription mode: the library maps a 401 from
40    /// the Codex subscription endpoint to this variant; the integrator
41    /// orchestrates `codex_refresh` + retry.
42    #[error("token expired: {0}")]
43    TokenExpired(String),
44
45    #[error("model not found: {0}")]
46    ModelNotFound(String),
47
48    #[error("unsupported functionality: {0}")]
49    Unsupported(String),
50
51    #[error("no such model: {0}")]
52    NoSuchModel(String),
53
54    #[error("unknown provider: {0}")]
55    UnknownProvider(String),
56
57    #[error("API call error: {0}")]
58    ApiCall(String),
59
60    #[error("request timed out: {0}")]
61    Timeout(String),
62
63    #[error("request aborted")]
64    Aborted,
65
66    #[error("{0}")]
67    Other(String),
68}
69
70impl From<serde_json::Error> for AiMuxError {
71    fn from(e: serde_json::Error) -> Self {
72        AiMuxError::Json(e.to_string())
73    }
74}
75
76impl AiMuxError {
77    /// Returns `true` if the error is transient and can be retried.
78    pub fn is_retryable(&self) -> bool {
79        matches!(
80            self,
81            AiMuxError::RateLimited { .. } | AiMuxError::Http(_) | AiMuxError::ApiCall(_)
82        )
83    }
84
85    /// Returns the retry-after delay hint (in milliseconds) carried by this
86    /// error, if any. Currently only `RateLimited` carries a hint (from a
87    /// `retry-after-ms` / `retry-after` response header). Returns `None` for
88    /// errors that don't advertise a delay, in which case the retry strategy
89    /// falls back to exponential backoff.
90    ///
91    /// Mirrors the header-consulting behaviour of
92    /// `retryWithExponentialBackoffRespectingRetryHeaders` in the TS SDK.
93    pub fn retry_after_hint(&self) -> Option<i64> {
94        match self {
95            AiMuxError::RateLimited { retry_after_ms } => Some(*retry_after_ms as i64),
96            _ => None,
97        }
98    }
99
100    /// Returns the error variant name as a string (e.g. `"Auth"`, `"Provider"`,
101    /// `"RateLimited"`). Used by FFI bindings to give callers a machine-readable
102    /// error type alongside the human-readable message.
103    pub fn error_type(&self) -> &'static str {
104        match self {
105            AiMuxError::Provider(_) => "Provider",
106            AiMuxError::Http(_) => "Http",
107            AiMuxError::Json(_) => "Json",
108            AiMuxError::Stream(_) => "Stream",
109            AiMuxError::Tool(_) => "Tool",
110            AiMuxError::InvalidArgument(_) => "InvalidArgument",
111            AiMuxError::InvalidPrompt(_) => "InvalidPrompt",
112            AiMuxError::RateLimited { .. } => "RateLimited",
113            AiMuxError::Auth(_) => "Auth",
114            AiMuxError::TokenExpired(_) => "TokenExpired",
115            AiMuxError::ModelNotFound(_) => "ModelNotFound",
116            AiMuxError::Unsupported(_) => "Unsupported",
117            AiMuxError::NoSuchModel(_) => "NoSuchModel",
118            AiMuxError::UnknownProvider(_) => "UnknownProvider",
119            AiMuxError::ApiCall(_) => "ApiCall",
120            AiMuxError::Timeout(_) => "Timeout",
121            AiMuxError::Aborted => "Aborted",
122            AiMuxError::Other(_) => "Other",
123        }
124    }
125
126    /// Returns the HTTP status code carried by this error, if any.
127    ///
128    /// HTTP-layer errors created by `parse_provider_error` and
129    /// `send_with_retry_raw` embed the status code in the message as
130    /// `"HTTP {status}: ..."`. This method extracts it. Errors that don't
131    /// originate from an HTTP response return `None`.
132    pub fn status_code(&self) -> Option<u16> {
133        let message = match self {
134            AiMuxError::Provider(m)
135            | AiMuxError::Http(m)
136            | AiMuxError::Auth(m)
137            | AiMuxError::ApiCall(m)
138            | AiMuxError::ModelNotFound(m) => m,
139            _ => return None,
140        };
141        // Parse "HTTP 403: ..." → 403
142        let rest = message.strip_prefix("HTTP ")?;
143        let code_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
144        code_str.parse().ok()
145    }
146}