1use 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#[derive(Debug, thiserror::Error)]
12pub enum CodexError {
13 #[error("HTTP request failed: {0}")]
15 Http(#[from] zenwave::Error),
16 #[error("JSON error: {0}")]
18 Json(#[from] serde_json::Error),
19 #[error("Codex API error: {0}")]
21 Api(String),
22 #[error("device authorization is still pending")]
24 AuthorizationPending,
25 #[error("OAuth callback error: {0}")]
27 Callback(String),
28 #[error("OAuth state mismatch")]
30 StateMismatch,
31 #[error("I/O error: {0}")]
33 Io(#[from] std::io::Error),
34 #[error("OpenAI error: {0}")]
36 OpenAI(#[from] aither_openai::OpenAIError),
37}
38
39fn 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 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
69fn 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}