Skip to main content

execution_policy/
error.rs

1//! Typed failure outcomes with rich, fail-fast diagnostic context.
2
3use std::time::Duration;
4
5/// Circuit-breaker state at the moment of failure.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum BreakerState {
8    Disabled,
9    Closed,
10    Open,
11    HalfOpen,
12}
13
14/// Diagnostic context attached to every [`ExecutionError`].
15#[derive(Debug, Clone)]
16pub struct ErrorContext {
17    pub attempts: u32,
18    pub elapsed: Duration,
19    pub last_delay: Option<Duration>,
20    pub breaker_state: BreakerState,
21}
22
23/// Why an execution failed. Boxed context keeps the hot `Result` small.
24#[non_exhaustive]
25#[derive(Debug)]
26pub enum ExecutionError<E> {
27    Operation {
28        source: E,
29        context: Box<ErrorContext>,
30    },
31    AttemptTimeout {
32        context: Box<ErrorContext>,
33    },
34    TotalTimeout {
35        context: Box<ErrorContext>,
36    },
37    CircuitOpen {
38        context: Box<ErrorContext>,
39    },
40    ConcurrencyRejected {
41        context: Box<ErrorContext>,
42    },
43    RetryBudgetExhausted {
44        context: Box<ErrorContext>,
45    },
46}
47
48impl<E> ExecutionError<E> {
49    /// Diagnostic context (attempts, elapsed, last delay, breaker state).
50    pub fn context(&self) -> &ErrorContext {
51        match self {
52            Self::Operation { context, .. }
53            | Self::AttemptTimeout { context }
54            | Self::TotalTimeout { context }
55            | Self::CircuitOpen { context }
56            | Self::ConcurrencyRejected { context }
57            | Self::RetryBudgetExhausted { context } => context,
58        }
59    }
60
61    /// Recover the underlying operation error, if this was an operation failure.
62    pub fn into_inner(self) -> Option<E> {
63        match self {
64            Self::Operation { source, .. } => Some(source),
65            _ => None,
66        }
67    }
68
69    pub fn is_timeout(&self) -> bool {
70        matches!(
71            self,
72            Self::AttemptTimeout { .. } | Self::TotalTimeout { .. }
73        )
74    }
75    pub fn is_circuit_open(&self) -> bool {
76        matches!(self, Self::CircuitOpen { .. })
77    }
78    pub fn is_rejected(&self) -> bool {
79        matches!(self, Self::ConcurrencyRejected { .. })
80    }
81    pub fn is_exhausted(&self) -> bool {
82        matches!(
83            self,
84            Self::Operation { .. } | Self::RetryBudgetExhausted { .. }
85        )
86    }
87}
88
89impl<E: std::fmt::Display> std::fmt::Display for ExecutionError<E> {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        let ctx = self.context();
92        match self {
93            Self::Operation { source, .. } => write!(
94                f,
95                "operation failed after {} attempt(s) in {:?}: {source}",
96                ctx.attempts, ctx.elapsed
97            ),
98            Self::AttemptTimeout { .. } => {
99                write!(f, "attempt timed out (attempt {})", ctx.attempts)
100            }
101            Self::TotalTimeout { .. } => {
102                write!(
103                    f,
104                    "total timeout after {:?} ({} attempts)",
105                    ctx.elapsed, ctx.attempts
106                )
107            }
108            Self::CircuitOpen { .. } => write!(f, "circuit open"),
109            Self::ConcurrencyRejected { .. } => {
110                write!(f, "concurrency limit rejected the call")
111            }
112            Self::RetryBudgetExhausted { .. } => write!(f, "retry budget exhausted"),
113        }
114    }
115}
116
117impl<E: std::error::Error + 'static> std::error::Error for ExecutionError<E> {
118    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
119        match self {
120            Self::Operation { source, .. } => Some(source),
121            _ => None,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn ctx() -> Box<ErrorContext> {
131        Box::new(ErrorContext {
132            attempts: 3,
133            elapsed: Duration::from_millis(120),
134            last_delay: Some(Duration::from_millis(50)),
135            breaker_state: BreakerState::Disabled,
136        })
137    }
138
139    #[test]
140    fn predicates_and_context() {
141        let e: ExecutionError<std::io::Error> = ExecutionError::TotalTimeout { context: ctx() };
142        assert!(e.is_timeout());
143        assert!(!e.is_circuit_open());
144        assert_eq!(e.context().attempts, 3);
145    }
146
147    #[test]
148    fn into_inner_recovers_operation_error() {
149        let src = std::io::Error::other("boom");
150        let e = ExecutionError::Operation {
151            source: src,
152            context: ctx(),
153        };
154        assert_eq!(e.into_inner().unwrap().to_string(), "boom");
155    }
156
157    #[test]
158    fn error_source_chains() {
159        use std::error::Error;
160        let src = std::io::Error::other("io fail");
161        let e = ExecutionError::Operation {
162            source: src,
163            context: ctx(),
164        };
165        assert!(e.source().is_some());
166    }
167}