1use std::future::Future;
26use std::pin::Pin;
27use std::time::{Duration, Instant};
28
29use crate::error::CoreError;
30
31const BACKOFF_CEILING: Duration = Duration::from_secs(30);
34
35#[derive(Debug, PartialEq, Eq)]
37pub enum PollState<T> {
38 Done(T),
40 Pending(Option<String>),
42}
43
44#[derive(Debug, Clone)]
46pub struct PollConfig {
47 pub subject: String,
50 pub interval: Duration,
52 pub max: Duration,
54 pub deadline: Duration,
56}
57
58impl Default for PollConfig {
59 fn default() -> Self {
60 Self {
61 subject: "poll".to_string(),
62 interval: Duration::from_secs(2),
63 max: BACKOFF_CEILING,
64 deadline: Duration::from_secs(120),
65 }
66 }
67}
68
69pub type Probe<'a, T> = Pin<Box<dyn Future<Output = Result<PollState<T>, CoreError>> + Send + 'a>>;
79
80fn next_interval(current: Duration, floor: Duration, ceiling: Duration) -> Duration {
83 current.mul_f64(1.5).clamp(floor, ceiling)
84}
85
86pub async fn poll<T, S, F>(cfg: PollConfig, state: S, mut probe: F) -> Result<T, CoreError>
96where
97 F: for<'a> FnMut(&'a mut S) -> Probe<'a, T>,
98{
99 let ceiling = cfg.max.min(BACKOFF_CEILING);
100 let started = Instant::now();
101 let mut interval = cfg.interval;
102 let mut state = state;
103 let mut last_observation: Option<String> = None;
104 loop {
105 match probe(&mut state).await {
106 Ok(PollState::Done(value)) => return Ok(value),
107 Ok(PollState::Pending(observation)) => last_observation = observation,
108 Err(err) if matches!(err, CoreError::Auth { .. }) => return Err(err),
110 Err(CoreError::Network { .. } | CoreError::GatewayRestarting { .. }) => {}
113 Err(other) => return Err(other),
115 }
116 let Some(remaining) = cfg.deadline.checked_sub(started.elapsed()) else {
117 return Err(deadline_error(&cfg, started.elapsed(), &last_observation));
118 };
119 if remaining.is_zero() {
120 return Err(deadline_error(&cfg, started.elapsed(), &last_observation));
121 }
122 tokio::time::sleep(interval.min(remaining)).await;
123 interval = next_interval(interval, cfg.interval, ceiling);
124 }
125}
126
127fn deadline_error(cfg: &PollConfig, waited: Duration, last: &Option<String>) -> CoreError {
136 CoreError::Network {
137 url: format!("{} — timed out after {waited:?}", cfg.subject),
138 source: None,
139 observation: last.clone(),
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use std::collections::VecDeque;
146 use std::sync::Mutex;
147 use std::time::Duration;
148
149 use super::{PollConfig, PollState, next_interval, poll};
150 use crate::error::CoreError;
151
152 async fn transport_error() -> reqwest::Error {
155 reqwest::get("http://127.0.0.1:1")
156 .await
157 .expect_err("dead port refuses")
158 }
159
160 struct FakeProbe {
162 steps: Mutex<VecDeque<Step>>,
163 }
164
165 enum Step {
166 Done(u32),
167 Pending(Option<String>),
168 Network,
169 Restarting,
170 Auth,
171 NotFound,
172 }
173
174 impl FakeProbe {
175 fn with(steps: Vec<Step>) -> Self {
176 Self {
177 steps: Mutex::new(steps.into()),
178 }
179 }
180
181 async fn next(&self) -> Result<PollState<u32>, CoreError> {
182 let step = self.steps.lock().unwrap().pop_front();
185 match step {
186 Some(Step::Done(value)) => Ok(PollState::Done(value)),
187 Some(Step::Pending(observation)) => Ok(PollState::Pending(observation)),
188 Some(Step::Network) => Err(CoreError::Network {
189 url: "http://127.0.0.1:1".into(),
190 source: Some(transport_error().await),
191 observation: None,
192 }),
193 Some(Step::Restarting) => Err(CoreError::GatewayRestarting {
194 endpoint: Some("http://127.0.0.1:1/data/api/v1/overview".into()),
195 }),
196 Some(Step::Auth) => Err(CoreError::Auth {
197 status: 401,
198 endpoint: None,
199 }),
200 Some(Step::NotFound) => Err(CoreError::NotFound { endpoint: None }),
201 None => panic!("scripted steps exhausted"),
202 }
203 }
204 }
205
206 fn counting_probe(
211 calls: std::sync::Arc<Mutex<usize>>,
212 ) -> impl for<'a> FnMut(&'a mut FakeProbe) -> super::Probe<'a, u32> {
213 move |rig| {
214 let calls = std::sync::Arc::clone(&calls);
215 Box::pin(async move {
216 *calls.lock().unwrap() += 1;
217 rig.next().await
218 })
219 }
220 }
221
222 fn counted_rig(steps: Vec<Step>) -> (FakeProbe, std::sync::Arc<Mutex<usize>>) {
223 let calls = std::sync::Arc::new(Mutex::new(0usize));
224 (FakeProbe::with(steps), std::sync::Arc::clone(&calls))
225 }
226
227 fn fast_cfg() -> PollConfig {
228 PollConfig {
229 subject: "test wait".into(),
230 interval: Duration::from_millis(1),
231 deadline: Duration::from_millis(60_000),
239 ..PollConfig::default()
240 }
241 }
242
243 #[tokio::test]
245 async fn success_first_poll() {
246 let (rig, calls) = counted_rig(vec![Step::Done(7)]);
247 let value = poll(fast_cfg(), rig, counting_probe(calls.clone()))
248 .await
249 .expect("immediate Done");
250 assert_eq!(value, 7);
251 assert_eq!(*calls.lock().unwrap(), 1);
252 }
253
254 #[tokio::test]
256 async fn transient_errors_are_retried_then_done() {
257 let (rig, calls) = counted_rig(vec![
258 Step::Network,
259 Step::Restarting,
260 Step::Pending(Some("almost".into())),
261 Step::Done(3),
262 ]);
263 let value = poll(fast_cfg(), rig, counting_probe(calls.clone()))
264 .await
265 .expect("transients retried to Done");
266 assert_eq!(value, 3);
267 assert_eq!(*calls.lock().unwrap(), 4);
268 }
269
270 #[tokio::test]
272 async fn auth_fails_immediately() {
273 let (rig, calls) = counted_rig(vec![Step::Auth, Step::Done(1)]);
274 let err = poll(fast_cfg(), rig, counting_probe(calls.clone()))
275 .await
276 .expect_err("auth aborts");
277 assert!(matches!(err, CoreError::Auth { status: 401, .. }));
278 assert_eq!(*calls.lock().unwrap(), 1, "no retry on auth");
279 }
280
281 #[tokio::test]
283 async fn other_errors_abort_immediately() {
284 let (rig, calls) = counted_rig(vec![Step::NotFound, Step::Done(1)]);
285 let err = poll(fast_cfg(), rig, counting_probe(calls.clone()))
286 .await
287 .expect_err("not-found aborts");
288 assert!(matches!(err, CoreError::NotFound { .. }));
289 assert_eq!(*calls.lock().unwrap(), 1);
290 }
291
292 #[tokio::test]
296 async fn deadline_expiry_is_network_class_with_observation() {
297 let calls = Mutex::new(0usize);
298 let err = poll(
299 PollConfig {
300 subject: "test readiness".into(),
301 interval: Duration::from_millis(1),
302 deadline: Duration::from_millis(20),
303 ..PollConfig::default()
304 },
305 &mut (),
306 |()| {
307 Box::pin(async {
308 *calls.lock().unwrap() += 1;
309 Ok(PollState::<()>::Pending(Some("obs-42".into())))
310 })
311 },
312 )
313 .await
314 .expect_err("deadline must expire");
315 assert!(
316 matches!(&err, CoreError::Network { source: None, .. }),
317 "deadline = Network with no transport source: {err}"
318 );
319 assert_eq!(err.exit_code(), 4);
320 assert_eq!(err.code(), "network_error");
321 let message = err.to_string();
322 assert!(
323 message.contains("test readiness"),
324 "subject named: {message}"
325 );
326 assert!(
327 message.contains("obs-42"),
328 "last observation carried: {message}"
329 );
330 assert!(message.contains("timed out"), "timeout named: {message}");
331 assert!(
332 !message.contains("unreachable"),
333 "an OBSERVED answer is never called unreachable (09-07): {message}"
334 );
335 assert!(
336 message.contains("no terminal state"),
337 "the observation-bearing lead: {message}"
338 );
339 assert!(*calls.lock().unwrap() > 1, "multiple polls before expiry");
340 }
341
342 #[tokio::test]
346 async fn deadline_without_observation_still_says_unreachable() {
347 let err = poll(
348 PollConfig {
349 subject: "silent wait".into(),
350 interval: Duration::from_millis(1),
351 deadline: Duration::from_millis(20),
352 ..PollConfig::default()
353 },
354 &mut (),
355 |()| Box::pin(async { Ok(PollState::<()>::Pending(None)) }),
356 )
357 .await
358 .expect_err("deadline must expire");
359 let message = err.to_string();
360 assert!(
361 message.starts_with("gateway unreachable at silent wait"),
362 "the no-observation wording preserved: {message}"
363 );
364 assert!(message.contains("timed out"), "timeout named: {message}");
365 assert!(
366 !message.contains("last observation"),
367 "no observation to carry: {message}"
368 );
369 }
370
371 #[test]
375 fn backoff_sequence_math() {
376 let floor = Duration::from_secs(2);
377 let ceiling = Duration::from_secs(30);
378 let mut current = floor;
379 let mut sequence = Vec::new();
380 for _ in 0..12 {
381 sequence.push(current);
382 current = next_interval(current, floor, ceiling);
383 }
384 assert_eq!(
385 sequence,
386 vec![
387 Duration::from_secs(2),
388 Duration::from_secs(3),
389 Duration::from_secs_f64(4.5),
390 Duration::from_secs_f64(6.75),
391 Duration::from_secs_f64(10.125),
392 Duration::from_secs_f64(15.1875),
393 Duration::from_secs_f64(22.781_25),
394 Duration::from_secs(30), Duration::from_secs(30),
396 Duration::from_secs(30),
397 Duration::from_secs(30),
398 Duration::from_secs(30),
399 ],
400 "×1.5 growth clamped to [interval, 30 s]"
401 );
402 let tight = next_interval(
405 Duration::from_secs(3),
406 Duration::from_secs(2),
407 Duration::from_secs(4),
408 );
409 assert_eq!(tight, Duration::from_secs(4));
410 let floored = next_interval(
411 Duration::from_secs(2),
412 Duration::from_secs(2),
413 Duration::from_secs(4),
414 );
415 assert_eq!(floored, Duration::from_secs(3), "3.0 s — floor unchanged");
416 }
417}