openzeppelin_relayer/jobs/
status_check_context.rs

1//! Status check context for circuit breaker decisions.
2//!
3//! This module provides the `StatusCheckContext` struct which carries failure tracking
4//! information to network handlers, enabling them to make intelligent decisions about
5//! when to force-finalize transactions that have exceeded retry limits.
6//!
7//! Two thresholds are used for circuit breaker decisions:
8//! - **Consecutive failures**: Triggers when RPC is completely down
9//! - **Total failures**: Safety net for flaky RPC that succeeds occasionally but keeps failing
10
11use std::collections::HashMap;
12
13use crate::constants::{EVM_MAX_CONSECUTIVE_STATUS_FAILURES, EVM_MAX_TOTAL_STATUS_FAILURES};
14use crate::models::NetworkType;
15
16/// Context for status check circuit breaker decisions.
17///
18/// This struct is passed to network handlers during status checks to provide
19/// failure tracking information. Handlers can use this to decide whether to
20/// force-finalize a transaction that has exceeded the maximum retry attempts.
21///
22/// The circuit breaker triggers when EITHER threshold is exceeded:
23/// - `consecutive_failures >= max_consecutive_failures` (RPC completely down)
24/// - `total_failures >= max_total_failures` (flaky RPC, safety net)
25///
26/// # Example
27///
28/// ```ignore
29/// let context = StatusCheckContext::new(
30///     consecutive_failures,
31///     total_failures,
32///     total_retries,
33///     max_consecutive_failures,
34///     max_total_failures,
35///     NetworkType::Stellar,
36/// );
37///
38/// if context.should_force_finalize() {
39///     // Mark transaction as Failed with appropriate reason
40/// }
41/// ```
42#[derive(Debug, Clone)]
43pub struct StatusCheckContext {
44    /// Number of consecutive failures since last successful status check.
45    /// Resets to 0 when a status check succeeds (even if transaction not final).
46    pub consecutive_failures: u32,
47
48    /// Total number of failures across all status check attempts.
49    /// Never resets - serves as safety net for flaky RPC connections.
50    pub total_failures: u32,
51
52    /// Total number of retries (from Apalis attempt counter).
53    /// Includes both successful and failed attempts.
54    pub total_retries: u32,
55
56    /// Maximum consecutive failures allowed before forcing finalization.
57    /// Network-specific value from constants.
58    pub max_consecutive_failures: u32,
59
60    /// Maximum total failures allowed before forcing finalization.
61    /// Safety net for flaky RPC that occasionally succeeds (resetting consecutive counter).
62    pub max_total_failures: u32,
63
64    /// The network type for this transaction.
65    pub network_type: NetworkType,
66
67    /// Optional metadata from the job that triggered this status check.
68    /// Used as a one-shot signal for special handling (e.g., nonce recovery hints).
69    /// Subsequent retries won't carry this metadata — they follow normal flow.
70    pub job_metadata: Option<HashMap<String, String>>,
71}
72
73impl Default for StatusCheckContext {
74    fn default() -> Self {
75        Self {
76            consecutive_failures: 0,
77            total_failures: 0,
78            total_retries: 0,
79            max_consecutive_failures: EVM_MAX_CONSECUTIVE_STATUS_FAILURES,
80            max_total_failures: EVM_MAX_TOTAL_STATUS_FAILURES,
81            network_type: NetworkType::Evm,
82            job_metadata: None,
83        }
84    }
85}
86
87impl StatusCheckContext {
88    /// Creates a new `StatusCheckContext` with the specified failure counts and limits.
89    ///
90    /// # Arguments
91    ///
92    /// * `consecutive_failures` - Current count of consecutive failures
93    /// * `total_failures` - Total count of all failures
94    /// * `total_retries` - Total Apalis retry attempts (includes successes)
95    /// * `max_consecutive_failures` - Network-specific consecutive max before force-finalization
96    /// * `max_total_failures` - Network-specific total max (safety net)
97    /// * `network_type` - The blockchain network type
98    pub fn new(
99        consecutive_failures: u32,
100        total_failures: u32,
101        total_retries: u32,
102        max_consecutive_failures: u32,
103        max_total_failures: u32,
104        network_type: NetworkType,
105    ) -> Self {
106        Self {
107            consecutive_failures,
108            total_failures,
109            total_retries,
110            max_consecutive_failures,
111            max_total_failures,
112            network_type,
113            job_metadata: None,
114        }
115    }
116
117    /// Attaches job metadata to this context (e.g., nonce recovery hints).
118    pub fn with_job_metadata(mut self, metadata: Option<HashMap<String, String>>) -> Self {
119        self.job_metadata = metadata;
120        self
121    }
122
123    /// Determines if the circuit breaker should force-finalize the transaction.
124    ///
125    /// Returns `true` if EITHER threshold is exceeded:
126    /// - Consecutive failures reached the network-specific maximum (RPC completely down)
127    /// - Total failures reached the network-specific maximum (flaky RPC safety net)
128    pub fn should_force_finalize(&self) -> bool {
129        self.consecutive_failures >= self.max_consecutive_failures
130            || self.total_failures >= self.max_total_failures
131    }
132
133    /// Returns true if triggered by consecutive failures threshold.
134    pub fn triggered_by_consecutive(&self) -> bool {
135        self.consecutive_failures >= self.max_consecutive_failures
136    }
137
138    /// Returns true if triggered by total failures threshold (safety net).
139    pub fn triggered_by_total(&self) -> bool {
140        self.total_failures >= self.max_total_failures
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_status_check_context_default() {
150        let ctx = StatusCheckContext::default();
151        assert_eq!(ctx.consecutive_failures, 0);
152        assert_eq!(ctx.total_failures, 0);
153        assert_eq!(ctx.total_retries, 0);
154        assert_eq!(
155            ctx.max_consecutive_failures,
156            EVM_MAX_CONSECUTIVE_STATUS_FAILURES
157        );
158        assert_eq!(ctx.max_total_failures, EVM_MAX_TOTAL_STATUS_FAILURES);
159        assert_eq!(ctx.network_type, NetworkType::Evm);
160    }
161
162    #[test]
163    fn test_status_check_context_new() {
164        let ctx = StatusCheckContext::new(5, 10, 20, 15, 45, NetworkType::Stellar);
165        assert_eq!(ctx.consecutive_failures, 5);
166        assert_eq!(ctx.total_failures, 10);
167        assert_eq!(ctx.total_retries, 20);
168        assert_eq!(ctx.max_consecutive_failures, 15);
169        assert_eq!(ctx.max_total_failures, 45);
170        assert_eq!(ctx.network_type, NetworkType::Stellar);
171    }
172
173    #[test]
174    fn test_should_force_finalize_below_both_thresholds() {
175        // consecutive: 5 < 15, total: 10 < 45
176        let ctx = StatusCheckContext::new(5, 10, 20, 15, 45, NetworkType::Evm);
177        assert!(!ctx.should_force_finalize());
178    }
179
180    #[test]
181    fn test_should_force_finalize_consecutive_at_threshold() {
182        // consecutive: 15 >= 15 (triggers), total: 20 < 45
183        let ctx = StatusCheckContext::new(15, 20, 30, 15, 45, NetworkType::Evm);
184        assert!(ctx.should_force_finalize());
185        assert!(ctx.triggered_by_consecutive());
186        assert!(!ctx.triggered_by_total());
187    }
188
189    #[test]
190    fn test_should_force_finalize_total_at_threshold() {
191        // consecutive: 5 < 15, total: 45 >= 45 (triggers - safety net)
192        let ctx = StatusCheckContext::new(5, 45, 50, 15, 45, NetworkType::Evm);
193        assert!(ctx.should_force_finalize());
194        assert!(!ctx.triggered_by_consecutive());
195        assert!(ctx.triggered_by_total());
196    }
197
198    #[test]
199    fn test_should_force_finalize_both_exceeded() {
200        // Both thresholds exceeded
201        let ctx = StatusCheckContext::new(20, 50, 60, 15, 45, NetworkType::Evm);
202        assert!(ctx.should_force_finalize());
203        assert!(ctx.triggered_by_consecutive());
204        assert!(ctx.triggered_by_total());
205    }
206
207    #[test]
208    fn test_flaky_rpc_scenario() {
209        // Simulates flaky RPC: consecutive keeps resetting but total grows
210        // consecutive: 3 < 15, total: 100 >= 45 (triggers safety net)
211        let ctx = StatusCheckContext::new(3, 100, 150, 15, 45, NetworkType::Evm);
212        assert!(ctx.should_force_finalize());
213        assert!(!ctx.triggered_by_consecutive());
214        assert!(ctx.triggered_by_total());
215    }
216}