Skip to main content

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}