1use oxios_ouroboros::FailureClass;
25
26pub fn classify(error: &anyhow::Error) -> FailureClass {
34 if let Some(typed) = downcast_class(error) {
36 return typed;
37 }
38
39 let chain = collect_chain(error);
41 match_pattern(&chain)
42}
43
44fn downcast_class(error: &anyhow::Error) -> Option<FailureClass> {
52 for cause in error.chain() {
53 if let Some(sdk_err) = cause.downcast_ref::<oxi_sdk::SdkError>() {
54 return Some(sdk_to_class(sdk_err));
55 }
56 }
57 None
58}
59
60fn sdk_to_class(err: &oxi_sdk::SdkError) -> FailureClass {
62 use oxi_sdk::SdkError;
63 match err {
64 SdkError::ModelNotFound { .. } | SdkError::ProviderNotFound { .. } => {
65 FailureClass::ModelUnavailable
66 }
67 SdkError::AllProvidersExhausted { .. } => FailureClass::Transient,
68 SdkError::TokenBudgetExceeded { .. } | SdkError::CostBudgetExceeded { .. } => {
69 FailureClass::BudgetExceeded
70 }
71 _ => FailureClass::Unknown,
72 }
73}
74
75fn collect_chain(error: &anyhow::Error) -> String {
78 let mut buf = String::new();
79 for (i, cause) in error.chain().enumerate() {
80 if i > 0 {
81 buf.push('\n');
82 }
83 buf.push_str(&cause.to_string());
84 }
85 buf.to_lowercase()
86}
87
88fn match_pattern(lower_chain: &str) -> FailureClass {
91 if contains_any(
97 lower_chain,
98 &["token budget", "cost budget", "budget exceeded"],
99 ) {
100 return FailureClass::BudgetExceeded;
101 }
102
103 if contains_any(
104 lower_chain,
105 &[
106 "402",
107 "payment required",
108 "quota",
109 "insufficient_quota",
110 "billing",
111 "credit balance",
112 "plan limit",
113 ],
114 ) {
115 return FailureClass::QuotaExhausted;
116 }
117
118 if contains_any(
119 lower_chain,
120 &[
121 "401",
122 "403",
123 "unauthorized",
124 "forbidden",
125 "invalid api key",
126 "missing api key",
127 "authentication",
128 "permission denied",
129 "access denied",
130 "credential",
131 ],
132 ) {
133 return FailureClass::AuthFailure;
134 }
135
136 if contains_any(
137 lower_chain,
138 &[
139 "model not found",
140 "unknown model",
141 "model does not exist",
142 "model deprecated",
143 "model_unsupported",
144 ],
145 ) {
146 return FailureClass::ModelUnavailable;
147 }
148
149 if contains_any(
150 lower_chain,
151 &[
152 "context overflow",
153 "context length",
154 "context_length",
155 "maximum context",
156 "context window",
157 "too many tokens",
158 "prompt is too long",
159 ],
160 ) {
161 return FailureClass::ContextOverflow;
162 }
163
164 if contains_any(
165 lower_chain,
166 &[
167 "429",
168 "too many requests",
169 "rate limit",
170 "rate limited",
171 "rate_limit",
172 "503",
173 "502",
174 "500",
175 "service unavailable",
176 "bad gateway",
177 "internal server error",
178 "overloaded",
179 "network error",
180 "connection",
181 "timeout",
182 "timed out", "deadline exceeded",
184 "request failed",
185 ],
186 ) {
187 return FailureClass::Transient;
188 }
189
190 FailureClass::Unknown
191}
192
193fn contains_any(haystack: &str, needles: &[&str]) -> bool {
194 needles.iter().any(|n| haystack.contains(n))
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use anyhow::anyhow;
201
202 fn err(msg: &str) -> anyhow::Error {
203 anyhow!("{}", msg)
204 }
205
206 #[test]
207 fn classifies_429_as_transient() {
208 assert_eq!(
209 classify(&err("HTTP error 429: rate limited")),
210 FailureClass::Transient
211 );
212 assert_eq!(
213 classify(&err("Rate limit exceeded (429)")),
214 FailureClass::Transient
215 );
216 }
217
218 #[test]
219 fn classifies_5xx_as_transient() {
220 assert_eq!(
221 classify(&err("HTTP error 503: service unavailable")),
222 FailureClass::Transient
223 );
224 assert_eq!(
225 classify(&err("HTTP error 502: bad gateway")),
226 FailureClass::Transient
227 );
228 assert_eq!(
229 classify(&err("HTTP error 500: internal server error")),
230 FailureClass::Transient
231 );
232 assert_eq!(
233 classify(&err("provider overloaded")),
234 FailureClass::Transient
235 );
236 }
237
238 #[test]
239 fn classifies_network_and_timeout_as_transient() {
240 assert_eq!(
241 classify(&err("network error: connection reset")),
242 FailureClass::Transient
243 );
244 assert_eq!(classify(&err("request timed out")), FailureClass::Transient);
245 assert_eq!(classify(&err("deadline exceeded")), FailureClass::Transient);
246 assert_eq!(
247 classify(&err("Request failed: dns lookup")),
248 FailureClass::Transient
249 );
250 }
251
252 #[test]
253 fn classifies_402_as_quota() {
254 assert_eq!(
255 classify(&err("HTTP error 402: payment required")),
256 FailureClass::QuotaExhausted
257 );
258 assert_eq!(
259 classify(&err("insufficient_quota")),
260 FailureClass::QuotaExhausted
261 );
262 assert_eq!(
263 classify(&err("billing: credit balance is 0")),
264 FailureClass::QuotaExhausted
265 );
266 }
267
268 #[test]
269 fn classifies_401_403_as_auth_failure() {
270 assert_eq!(
271 classify(&err("HTTP error 401: unauthorized")),
272 FailureClass::AuthFailure
273 );
274 assert_eq!(
275 classify(&err("HTTP error 403: forbidden")),
276 FailureClass::AuthFailure
277 );
278 assert_eq!(classify(&err("Missing API key")), FailureClass::AuthFailure);
279 assert_eq!(
280 classify(&err("Invalid API key format")),
281 FailureClass::AuthFailure
282 );
283 }
284
285 #[test]
286 fn classifies_context_overflow() {
287 assert_eq!(
288 classify(&err("Context overflow")),
289 FailureClass::ContextOverflow
290 );
291 assert_eq!(
292 classify(&err("context length exceeded maximum")),
293 FailureClass::ContextOverflow
294 );
295 assert_eq!(
296 classify(&err("prompt is too long for the model")),
297 FailureClass::ContextOverflow
298 );
299 }
300
301 #[test]
302 fn classifies_model_unavailable() {
303 assert_eq!(
304 classify(&err("model not found: foo/bar")),
305 FailureClass::ModelUnavailable
306 );
307 assert_eq!(
308 classify(&err("unknown model 'gpt-9'")),
309 FailureClass::ModelUnavailable
310 );
311 }
312
313 #[test]
314 fn classifies_budget_exceeded() {
315 assert_eq!(
316 classify(&err("token budget exceeded: 10000 / 8000")),
317 FailureClass::BudgetExceeded
318 );
319 assert_eq!(
320 classify(&err("cost budget exceeded: $0.50 / $0.40")),
321 FailureClass::BudgetExceeded
322 );
323 }
324
325 #[test]
326 fn classifies_anything_else_as_unknown() {
327 assert_eq!(
328 classify(&err("something went wrong")),
329 FailureClass::Unknown
330 );
331 assert_eq!(classify(&err("")), FailureClass::Unknown);
332 }
333
334 #[test]
335 fn cause_chain_walks_buried_signals() {
336 let outer = anyhow!("Failed after 3 retries: HTTP error 429: rate limited");
337 assert_eq!(classify(&outer), FailureClass::Transient);
338
339 let outer = anyhow!("Failed after 3 retries: HTTP error 401: unauthorized");
340 assert_eq!(classify(&outer), FailureClass::AuthFailure);
341
342 let outer = anyhow!("Failed after 3 retries: insufficient_quota");
343 assert_eq!(classify(&outer), FailureClass::QuotaExhausted);
344 }
345
346 #[test]
347 fn case_insensitive() {
348 assert_eq!(
349 classify(&err("HTTP ERROR 429: RATE LIMITED")),
350 FailureClass::Transient
351 );
352 assert_eq!(
353 classify(&err("Context Overflow")),
354 FailureClass::ContextOverflow
355 );
356 }
357}