Skip to main content

oxios_kernel/resilience/
classify.rs

1//! Failure classification heuristics (RFC-029 §3.3).
2//!
3//! The strategy, in priority order:
4//!
5//! 1. **Downcast fast-path** — attempt to downcast to oxi-sdk structured
6//!    error types. Today the typed `ProviderError` is stringified at the
7//!    oxi-agent boundary, so this rarely succeeds. Kept for forward
8//!    compatibility if upstream preserves the type in a future release.
9//! 2. **Cause-chain Display walk** — collect every cause's Display
10//!    string, then pattern-match. The chain matters because the
11//!    outermost `anyhow::Error` is often a wrapper like "Agent run
12//!    failed" and the actual `ProviderError` Display is buried one or
13//!    two levels deep (e.g. `AgentError::RetriesExhausted { last_error }`
14//!    → `last_error: String`).
15//! 3. **Default** — `FailureClass::Unknown`. Conservative: the
16//!    coordinator will attempt one same-model retry, then escalate.
17//!
18//! Patterns are matched case-insensitively against the lowercased
19//! concatenated cause chain. Keep them under test (this file's
20//! `#[cfg(test)]` block) and review on oxi-ai upgrades — a provider
21//! changing its error wording degrades classification to Unknown, which
22//! is the safe fallback.
23
24use oxios_ouroboros::FailureClass;
25
26/// Classify an `anyhow::Error` into a `FailureClass`.
27///
28/// The error is expected to come from `agent.run_streaming()` after
29/// oxi-agent's internal retries are exhausted (typically as
30/// `AgentError::RetriesExhausted { last_error }` or
31/// `AgentError::Stream(...)`). The classification is best-effort; when
32/// in doubt, returns `FailureClass::Unknown`.
33pub fn classify(error: &anyhow::Error) -> FailureClass {
34    // 1. Downcast fast-path (forward-compat).
35    if let Some(typed) = downcast_class(error) {
36        return typed;
37    }
38
39    // 2. Cause-chain Display walk.
40    let chain = collect_chain(error);
41    match_pattern(&chain)
42}
43
44/// Attempt to downcast the error (and its causes) to a typed variant
45/// that carries an unambiguous classification signal. Returns `Some` on
46/// the first match.
47///
48/// Today oxi-agent stringifies `ProviderError` so this almost never
49/// fires. Kept so that a future upstream change preserving the type
50/// automatically yields better classification.
51fn 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
60/// Map an `oxi_sdk::SdkError` to a `FailureClass` (typed fast-path).
61fn 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
75/// Collect the full Display text of the error and every cause,
76/// joined by newlines. The whole blob is lowercased once.
77fn 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
88/// Match the collected cause chain (already lowercased) against the
89/// classification patterns.
90fn match_pattern(lower_chain: &str) -> FailureClass {
91    // Order matters: check the more specific patterns first so that
92    // e.g. "rate limit" doesn't pre-empt "quota" if both happen to
93    // appear. In practice, providers emit one signal at a time, but
94    // being explicit avoids surprises.
95
96    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", // oxi-ai ProviderError::Timeout Display
183            "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}