Skip to main content

aither_codex/
error.rs

1//! Error type for the Codex provider.
2
3use std::time::Duration;
4
5use aither_core::llm::{
6    Classify, Failure, FailureKind,
7    failure::zenwave::{failure as http_failure, io_is_network},
8};
9
10/// Errors from Codex OAuth flows and API calls.
11#[derive(Debug, thiserror::Error)]
12pub enum CodexError {
13    /// HTTP transport or status error.
14    #[error("HTTP request failed: {0}")]
15    Http(#[from] zenwave::Error),
16    /// JSON serialization/deserialization error.
17    #[error("JSON error: {0}")]
18    Json(#[from] serde_json::Error),
19    /// Error body returned by an endpoint (`detail` field).
20    #[error("Codex API error: {0}")]
21    Api(String),
22    /// The user has not completed device authorization yet.
23    #[error("device authorization is still pending")]
24    AuthorizationPending,
25    /// The OAuth loopback listener failed or the callback did not arrive.
26    #[error("OAuth callback error: {0}")]
27    Callback(String),
28    /// The callback `state` did not match the issued one.
29    #[error("OAuth state mismatch")]
30    StateMismatch,
31    /// I/O error (e.g. binding the loopback listener).
32    #[error("I/O error: {0}")]
33    Io(#[from] std::io::Error),
34    /// Error from the underlying `OpenAI` client.
35    #[error("OpenAI error: {0}")]
36    OpenAI(#[from] aither_openai::OpenAIError),
37}
38
39/// Marks `failure` as a quota exhaustion when the error body is the
40/// `usage_limit_reached` error the `ChatGPT` backend answers with once the
41/// subscription's usage window is spent, and takes its reset time as the delay.
42fn apply_usage_limit(error: &zenwave::Error, failure: &mut Failure) {
43    #[derive(serde::Deserialize)]
44    struct Body {
45        error: Detail,
46    }
47    #[derive(serde::Deserialize)]
48    struct Detail {
49        #[serde(rename = "type")]
50        error_type: Option<String>,
51        /// When the limit resets, in Unix seconds.
52        resets_at: Option<u64>,
53    }
54    let Some(detail) = error
55        .response_body()
56        .and_then(|body| serde_json::from_str::<Body>(body).ok())
57        .map(|body| body.error)
58    else {
59        return;
60    };
61    if detail.error_type.as_deref() == Some("usage_limit_reached") {
62        failure.kind = FailureKind::Quota;
63        failure.retry_after = failure
64            .retry_after
65            .or_else(|| detail.resets_at.map(until_unix_seconds));
66    }
67}
68
69/// Time from now until `resets_at` (Unix seconds), zero once it has passed.
70fn until_unix_seconds(resets_at: u64) -> Duration {
71    let now = std::time::SystemTime::now()
72        .duration_since(std::time::UNIX_EPOCH)
73        .expect("the system clock is after the Unix epoch");
74    Duration::from_secs(resets_at).saturating_sub(now)
75}
76
77impl Classify for CodexError {
78    fn failure(&self) -> Failure {
79        match self {
80            Self::Http(err) => {
81                let mut failure = http_failure(err);
82                apply_usage_limit(err, &mut failure);
83                failure
84            }
85            Self::OpenAI(inner @ aither_openai::OpenAIError::Http(err)) => {
86                let mut failure = inner.failure();
87                apply_usage_limit(err, &mut failure);
88                failure
89            }
90            Self::OpenAI(err) => err.failure(),
91            Self::Io(err) if io_is_network(err.kind()) => Failure::network(),
92            Self::Json(_)
93            | Self::Api(_)
94            | Self::AuthorizationPending
95            | Self::Callback(_)
96            | Self::StateMismatch
97            | Self::Io(_) => Failure::permanent(),
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::time::Duration;
105
106    use aither_core::llm::{Classify, FailureKind, failure::zenwave::http_error};
107    use aither_openai::OpenAIError;
108
109    use super::CodexError;
110
111    fn http(status: u16, headers: &[(&str, &str)], body: &str) -> CodexError {
112        CodexError::OpenAI(OpenAIError::Http(http_error(status, headers, body)))
113    }
114
115    #[test]
116    fn server_errors_are_transient() {
117        assert_eq!(http(500, &[], "").failure().kind, FailureKind::Transient);
118        assert_eq!(http(529, &[], "").failure().kind, FailureKind::Transient);
119    }
120
121    #[test]
122    fn usage_limit_is_quota_with_reset() {
123        let now = std::time::SystemTime::now()
124            .duration_since(std::time::UNIX_EPOCH)
125            .unwrap()
126            .as_secs();
127        let body = format!(
128            r#"{{"error":{{"type":"usage_limit_reached","resets_at":{}}}}}"#,
129            now + 3600
130        );
131        let body = body.as_str();
132        let failure = http(429, &[], body).failure();
133        assert_eq!(failure.kind, FailureKind::Quota);
134        let retry_after = failure.retry_after.expect("the reset time is read");
135        assert!(
136            retry_after > Duration::from_secs(3590) && retry_after <= Duration::from_secs(3600)
137        );
138        let direct = CodexError::Http(http_error(429, &[], body)).failure();
139        assert_eq!(direct.kind, FailureKind::Quota);
140        assert_eq!(http(429, &[], "").failure().kind, FailureKind::Transient);
141    }
142
143    #[test]
144    fn unauthorized_is_permanent() {
145        assert_eq!(http(401, &[], "").failure().kind, FailureKind::Permanent);
146    }
147
148    #[test]
149    fn retry_after_seconds_and_date() {
150        let failure = http(429, &[("retry-after", "4")], "").failure();
151        assert_eq!(failure.retry_after, Some(Duration::from_secs(4)));
152        let failure = http(503, &[("retry-after", "Fri, 01 Jan 2100 00:00:00 GMT")], "").failure();
153        assert!(
154            failure
155                .retry_after
156                .is_some_and(|d| d > Duration::from_secs(3600))
157        );
158    }
159
160    #[test]
161    fn refused_connection_is_network() {
162        use std::io::{Error, ErrorKind};
163        let error = CodexError::Io(Error::from(ErrorKind::ConnectionRefused));
164        assert_eq!(error.failure().kind, FailureKind::Network);
165    }
166}