oxios_ouroboros/resilience.rs
1//! Resilience types — failure classification shared by the kernel and
2//! any consumer that needs to reason about *why* an agent run failed.
3//!
4//! This is the result-contract side of the resilience pipeline (see
5//! `docs/rfc-029-execution-resilience.md`). The classification *logic*
6//! (heuristic, cause-chain walk) lives in `oxios-kernel::resilience::classify`
7//! to avoid pulling kernel-only dependencies (anyhow, tracing) downstream.
8//!
9//! # Honest limitation
10//!
11//! The typed `oxi_ai::ProviderError` is stringified at the oxi-agent
12//! boundary (`From<anyhow::Error>` → `AgentError::Stream(String)` /
13//! `RetriesExhausted { last_error: String }`). Downcasting to
14//! `ProviderError` across that boundary is **not possible** today. The
15//! kernel's `classify` function therefore uses message-pattern heuristics
16//! on the `Display` string. The variants here are the *contract*; the
17//! patterns are tested and reviewed on oxi-ai upgrades.
18//!
19//! Keep this enum, its `Serialize`/`Deserialize` shape, and its serde
20//! representation stable — it travels over the kernel ↔ gateway boundary
21//! and will be persisted in the agent log (RFC-029 §3.6).
22
23use serde::{Deserialize, Serialize};
24
25/// What kind of failure occurred, and the recovery it implies.
26///
27/// Produced by `oxios-kernel::resilience::classify::classify` and attached
28/// to [`ExecutionResult`](crate::ExecutionResult) via
29/// [`ExecutionResult::failure_class`].
30///
31/// `None` on `ExecutionResult.failure_class` means either the run succeeded
32/// or the run failed in a way that is NOT a provider/infra failure
33/// (cancellation, abort, missing agent, etc.).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum FailureClass {
37 /// 429 / 5xx / network / timeout. Same model may recover with backoff.
38 Transient,
39 /// 402 billing / quota exhausted. Waiting is pointless — change provider.
40 QuotaExhausted,
41 /// 401 / 403 invalid key. Must change provider or credential.
42 AuthFailure,
43 /// Context window exceeded. Compact, or switch to larger-context model.
44 ContextOverflow,
45 /// Model not found / deprecated / unsupported. Must switch model.
46 ModelUnavailable,
47 /// Token or cost budget hit — a policy limit, not a provider fault.
48 BudgetExceeded,
49 /// Unclassified. Conservative: one same-model retry, then escalate.
50 Unknown,
51}
52
53impl FailureClass {
54 /// Does waiting + retrying the SAME model have a chance of working?
55 ///
56 /// Used by the recovery coordinator (RFC-029 §3.4 L1) to decide
57 /// whether to attempt a same-model backoff before model/provider swap.
58 pub fn benefits_from_same_model_retry(&self) -> bool {
59 matches!(self, Self::Transient | Self::Unknown)
60 }
61
62 /// Must we switch provider (waiting on the same one won't help)?
63 ///
64 /// Quota and auth failures cannot be fixed by waiting on the same
65 /// provider — they need a different credential/account.
66 pub fn requires_provider_swap(&self) -> bool {
67 matches!(self, Self::QuotaExhausted | Self::AuthFailure)
68 }
69
70 /// Should the recovery coordinator skip a from-scratch retry and
71 /// go straight to model/provider swap? Used for failures that won't
72 /// recover with the same model even with a fresh agent (rate limit,
73 /// quota, auth, missing model).
74 pub fn should_skip_same_model_retry(&self) -> bool {
75 matches!(
76 self,
77 Self::QuotaExhausted
78 | Self::AuthFailure
79 | Self::ModelUnavailable
80 | Self::BudgetExceeded
81 )
82 }
83}
84
85impl std::fmt::Display for FailureClass {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 let s = match self {
88 Self::Transient => "transient",
89 Self::QuotaExhausted => "quota_exhausted",
90 Self::AuthFailure => "auth_failure",
91 Self::ContextOverflow => "context_overflow",
92 Self::ModelUnavailable => "model_unavailable",
93 Self::BudgetExceeded => "budget_exceeded",
94 Self::Unknown => "unknown",
95 };
96 f.write_str(s)
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn requires_provider_swap_matches_only_quota_and_auth() {
106 assert!(FailureClass::QuotaExhausted.requires_provider_swap());
107 assert!(FailureClass::AuthFailure.requires_provider_swap());
108 assert!(!FailureClass::Transient.requires_provider_swap());
109 assert!(!FailureClass::ContextOverflow.requires_provider_swap());
110 assert!(!FailureClass::ModelUnavailable.requires_provider_swap());
111 assert!(!FailureClass::BudgetExceeded.requires_provider_swap());
112 assert!(!FailureClass::Unknown.requires_provider_swap());
113 }
114
115 #[test]
116 fn benefits_from_same_model_retry_matches_transient_and_unknown() {
117 assert!(FailureClass::Transient.benefits_from_same_model_retry());
118 assert!(FailureClass::Unknown.benefits_from_same_model_retry());
119 for v in [
120 FailureClass::QuotaExhausted,
121 FailureClass::AuthFailure,
122 FailureClass::ContextOverflow,
123 FailureClass::ModelUnavailable,
124 FailureClass::BudgetExceeded,
125 ] {
126 assert!(
127 !v.benefits_from_same_model_retry(),
128 "{v} should not benefit from same-model retry"
129 );
130 }
131 }
132
133 #[test]
134 fn serde_roundtrip() {
135 for v in [
136 FailureClass::Transient,
137 FailureClass::QuotaExhausted,
138 FailureClass::AuthFailure,
139 FailureClass::ContextOverflow,
140 FailureClass::ModelUnavailable,
141 FailureClass::BudgetExceeded,
142 FailureClass::Unknown,
143 ] {
144 let json = serde_json::to_string(&v).unwrap();
145 let back: FailureClass = serde_json::from_str(&json).unwrap();
146 assert_eq!(v, back, "roundtrip failed for {v}");
147 }
148 }
149
150 #[test]
151 fn display_is_snake_case() {
152 assert_eq!(FailureClass::Transient.to_string(), "transient");
153 assert_eq!(FailureClass::QuotaExhausted.to_string(), "quota_exhausted");
154 assert_eq!(
155 FailureClass::ContextOverflow.to_string(),
156 "context_overflow"
157 );
158 assert_eq!(
159 FailureClass::ModelUnavailable.to_string(),
160 "model_unavailable"
161 );
162 assert_eq!(FailureClass::BudgetExceeded.to_string(), "budget_exceeded");
163 assert_eq!(FailureClass::AuthFailure.to_string(), "auth_failure");
164 assert_eq!(FailureClass::Unknown.to_string(), "unknown");
165 }
166}