bot_engine/poll_guard.rs
1//! Generic poll handler with backoff and error classification.
2//!
3//! Encapsulates the retry/backoff/health concerns shared by all polling
4//! operations (fills, quotes, account state). Callers get a clean
5//! `PollOutcome` enum and only handle the data path.
6
7use crate::compat::sleep;
8use crate::runner::RunnerConfig;
9use bot_core::ExchangeError;
10use std::time::Duration;
11
12// ---------------------------------------------------------------------------
13// HasItems trait — lets PollGuard distinguish "got data" from "got empty Ok"
14// ---------------------------------------------------------------------------
15
16/// Trait to check if a poll result contains actual data.
17///
18/// Blanket-implemented for `Vec<T>`, so `Vec<Fill>`, `Vec<Quote>`, etc.
19/// all satisfy this automatically.
20pub trait HasItems {
21 /// Return `true` when the poll result contains meaningful data.
22 fn has_items(&self) -> bool;
23}
24
25impl<T> HasItems for Vec<T> {
26 fn has_items(&self) -> bool {
27 !self.is_empty()
28 }
29}
30
31// ---------------------------------------------------------------------------
32// PollOutcome — the only thing callers see
33// ---------------------------------------------------------------------------
34
35/// Result of a guarded poll operation.
36pub enum PollOutcome<T> {
37 /// Got data — backoff reset, consecutive empties reset.
38 Data(T),
39 /// Got Ok([]) or transient error — backoff applied internally.
40 Empty,
41 /// Transient but health-affecting error (e.g. 502/503).
42 /// Caller should set health to Halted; guard still applies backoff internally.
43 Degraded(ExchangeError),
44 /// Fatal (non-transient) error — caller should degrade health.
45 Fatal(ExchangeError),
46}
47
48// ---------------------------------------------------------------------------
49// PollGuard — per-(exchange, operation) state machine
50// ---------------------------------------------------------------------------
51
52/// Per-poll-operation state machine that handles backoff, error classification,
53/// and consecutive-empty tracking.
54///
55/// Create one per `(exchange, poll_type)` — fills and quotes can fail
56/// independently and need separate backoff clocks.
57pub struct PollGuard {
58 label: &'static str,
59 backoff_ms: u64,
60 initial_backoff_ms: u64,
61 max_backoff_ms: u64,
62 backoff_multiplier: f64,
63 consecutive_empties: u32,
64}
65
66impl PollGuard {
67 /// Create a new guard with backoff config derived from `RunnerConfig`.
68 pub fn new(label: &'static str, config: &RunnerConfig) -> Self {
69 Self {
70 label,
71 backoff_ms: 0,
72 initial_backoff_ms: config.initial_backoff_ms,
73 max_backoff_ms: config.max_backoff_ms,
74 backoff_multiplier: config.backoff_multiplier,
75 consecutive_empties: 0,
76 }
77 }
78
79 /// Execute a poll with built-in backoff + error handling.
80 ///
81 /// The skeleton:
82 /// 1. Apply current backoff delay (if any).
83 /// 2. Call `poll_fn`.
84 /// 3. Classify the result and update internal state.
85 /// 4. Return a clean `PollOutcome` so the caller only handles data.
86 pub async fn execute<T, F, Fut>(&mut self, poll_fn: F) -> PollOutcome<T>
87 where
88 T: HasItems,
89 F: FnOnce() -> Fut,
90 Fut: std::future::Future<Output = Result<T, ExchangeError>>,
91 {
92 // 1. Apply backoff delay
93 if self.backoff_ms > 0 {
94 sleep(Duration::from_millis(self.backoff_ms)).await;
95 }
96
97 // 2. Call the actual poll
98 match poll_fn().await {
99 Ok(result) if result.has_items() => {
100 // Got data — reset everything
101 self.backoff_ms = 0;
102 self.consecutive_empties = 0;
103 PollOutcome::Data(result)
104 }
105 Ok(_empty) => {
106 // Empty response — API is healthy, reset backoff but track emptiness
107 self.backoff_ms = 0;
108 self.consecutive_empties += 1;
109 tracing::debug!(
110 "[PollGuard:{}] Empty response ({}x consecutive)",
111 self.label,
112 self.consecutive_empties
113 );
114 PollOutcome::Empty
115 }
116 Err(e) if e.is_transient() => {
117 // Transient error — backoff and retry next iteration
118 self.increase_backoff();
119
120 if e.is_502() {
121 // 502/503 is transient (retry) but also health-affecting
122 tracing::warn!(
123 "[PollGuard:{}] Exchange unavailable, backoff={}ms: {}",
124 self.label,
125 self.backoff_ms,
126 e
127 );
128 PollOutcome::Degraded(e)
129 } else {
130 tracing::warn!(
131 "[PollGuard:{}] Transient error, backoff={}ms: {}",
132 self.label,
133 self.backoff_ms,
134 e
135 );
136 PollOutcome::Empty
137 }
138 }
139 Err(e) => {
140 // Fatal error — caller should degrade health
141 tracing::error!("[PollGuard:{}] Fatal error: {}", self.label, e);
142 PollOutcome::Fatal(e)
143 }
144 }
145 }
146
147 /// Check if the poll source looks exhausted.
148 ///
149 /// Used by the runner for backtest exit detection:
150 /// `if delay == 0 && guard.looks_exhausted(3) { break; }`
151 pub fn looks_exhausted(&self, threshold: u32) -> bool {
152 self.consecutive_empties >= threshold
153 }
154
155 /// Exponential backoff with cap.
156 fn increase_backoff(&mut self) {
157 self.backoff_ms = if self.backoff_ms == 0 {
158 self.initial_backoff_ms
159 } else {
160 ((self.backoff_ms as f64) * self.backoff_multiplier) as u64
161 };
162 self.backoff_ms = self.backoff_ms.min(self.max_backoff_ms);
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn has_items_vec() {
172 let empty: Vec<i32> = vec![];
173 assert!(!empty.has_items());
174
175 let non_empty = vec![1, 2, 3];
176 assert!(non_empty.has_items());
177 }
178
179 #[test]
180 fn looks_exhausted_threshold() {
181 let config = RunnerConfig::default();
182 let mut guard = PollGuard::new("test", &config);
183
184 assert!(!guard.looks_exhausted(3));
185
186 guard.consecutive_empties = 2;
187 assert!(!guard.looks_exhausted(3));
188
189 guard.consecutive_empties = 3;
190 assert!(guard.looks_exhausted(3));
191 }
192
193 #[test]
194 fn increase_backoff_exponential() {
195 let config = RunnerConfig {
196 initial_backoff_ms: 100,
197 max_backoff_ms: 1000,
198 backoff_multiplier: 2.0,
199 ..RunnerConfig::default()
200 };
201 let mut guard = PollGuard::new("test", &config);
202
203 // First backoff: 0 → initial
204 guard.increase_backoff();
205 assert_eq!(guard.backoff_ms, 100);
206
207 // Second: 100 * 2 = 200
208 guard.increase_backoff();
209 assert_eq!(guard.backoff_ms, 200);
210
211 // Third: 200 * 2 = 400
212 guard.increase_backoff();
213 assert_eq!(guard.backoff_ms, 400);
214
215 // Fourth: 400 * 2 = 800
216 guard.increase_backoff();
217 assert_eq!(guard.backoff_ms, 800);
218
219 // Fifth: 800 * 2 = 1600 → cap at 1000
220 guard.increase_backoff();
221 assert_eq!(guard.backoff_ms, 1000);
222 }
223}