Skip to main content

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}