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}