use std::future::Future;
use std::pin::Pin;
use std::time::{Duration, Instant};
use crate::error::CoreError;
const BACKOFF_CEILING: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Eq)]
pub enum PollState<T> {
Done(T),
Pending(Option<String>),
}
#[derive(Debug, Clone)]
pub struct PollConfig {
pub subject: String,
pub interval: Duration,
pub max: Duration,
pub deadline: Duration,
}
impl Default for PollConfig {
fn default() -> Self {
Self {
subject: "poll".to_string(),
interval: Duration::from_secs(2),
max: BACKOFF_CEILING,
deadline: Duration::from_secs(120),
}
}
}
pub type Probe<'a, T> = Pin<Box<dyn Future<Output = Result<PollState<T>, CoreError>> + Send + 'a>>;
fn next_interval(current: Duration, floor: Duration, ceiling: Duration) -> Duration {
current.mul_f64(1.5).clamp(floor, ceiling)
}
pub async fn poll<T, S, F>(cfg: PollConfig, state: S, mut probe: F) -> Result<T, CoreError>
where
F: for<'a> FnMut(&'a mut S) -> Probe<'a, T>,
{
let ceiling = cfg.max.min(BACKOFF_CEILING);
let started = Instant::now();
let mut interval = cfg.interval;
let mut state = state;
let mut last_observation: Option<String> = None;
loop {
match probe(&mut state).await {
Ok(PollState::Done(value)) => return Ok(value),
Ok(PollState::Pending(observation)) => last_observation = observation,
Err(err) if matches!(err, CoreError::Auth { .. }) => return Err(err),
Err(CoreError::Network { .. } | CoreError::GatewayRestarting { .. }) => {}
Err(other) => return Err(other),
}
let Some(remaining) = cfg.deadline.checked_sub(started.elapsed()) else {
return Err(deadline_error(&cfg, started.elapsed(), &last_observation));
};
if remaining.is_zero() {
return Err(deadline_error(&cfg, started.elapsed(), &last_observation));
}
tokio::time::sleep(interval.min(remaining)).await;
interval = next_interval(interval, cfg.interval, ceiling);
}
}
fn deadline_error(cfg: &PollConfig, waited: Duration, last: &Option<String>) -> CoreError {
CoreError::Network {
url: format!("{} — timed out after {waited:?}", cfg.subject),
source: None,
observation: last.clone(),
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Duration;
use super::{PollConfig, PollState, next_interval, poll};
use crate::error::CoreError;
async fn transport_error() -> reqwest::Error {
reqwest::get("http://127.0.0.1:1")
.await
.expect_err("dead port refuses")
}
struct FakeProbe {
steps: Mutex<VecDeque<Step>>,
}
enum Step {
Done(u32),
Pending(Option<String>),
Network,
Restarting,
Auth,
NotFound,
}
impl FakeProbe {
fn with(steps: Vec<Step>) -> Self {
Self {
steps: Mutex::new(steps.into()),
}
}
async fn next(&self) -> Result<PollState<u32>, CoreError> {
let step = self.steps.lock().unwrap().pop_front();
match step {
Some(Step::Done(value)) => Ok(PollState::Done(value)),
Some(Step::Pending(observation)) => Ok(PollState::Pending(observation)),
Some(Step::Network) => Err(CoreError::Network {
url: "http://127.0.0.1:1".into(),
source: Some(transport_error().await),
observation: None,
}),
Some(Step::Restarting) => Err(CoreError::GatewayRestarting {
endpoint: Some("http://127.0.0.1:1/data/api/v1/overview".into()),
}),
Some(Step::Auth) => Err(CoreError::Auth {
status: 401,
endpoint: None,
}),
Some(Step::NotFound) => Err(CoreError::NotFound { endpoint: None }),
None => panic!("scripted steps exhausted"),
}
}
}
fn counting_probe(
calls: std::sync::Arc<Mutex<usize>>,
) -> impl for<'a> FnMut(&'a mut FakeProbe) -> super::Probe<'a, u32> {
move |rig| {
let calls = std::sync::Arc::clone(&calls);
Box::pin(async move {
*calls.lock().unwrap() += 1;
rig.next().await
})
}
}
fn counted_rig(steps: Vec<Step>) -> (FakeProbe, std::sync::Arc<Mutex<usize>>) {
let calls = std::sync::Arc::new(Mutex::new(0usize));
(FakeProbe::with(steps), std::sync::Arc::clone(&calls))
}
fn fast_cfg() -> PollConfig {
PollConfig {
subject: "test wait".into(),
interval: Duration::from_millis(1),
deadline: Duration::from_millis(60_000),
..PollConfig::default()
}
}
#[tokio::test]
async fn success_first_poll() {
let (rig, calls) = counted_rig(vec![Step::Done(7)]);
let value = poll(fast_cfg(), rig, counting_probe(calls.clone()))
.await
.expect("immediate Done");
assert_eq!(value, 7);
assert_eq!(*calls.lock().unwrap(), 1);
}
#[tokio::test]
async fn transient_errors_are_retried_then_done() {
let (rig, calls) = counted_rig(vec![
Step::Network,
Step::Restarting,
Step::Pending(Some("almost".into())),
Step::Done(3),
]);
let value = poll(fast_cfg(), rig, counting_probe(calls.clone()))
.await
.expect("transients retried to Done");
assert_eq!(value, 3);
assert_eq!(*calls.lock().unwrap(), 4);
}
#[tokio::test]
async fn auth_fails_immediately() {
let (rig, calls) = counted_rig(vec![Step::Auth, Step::Done(1)]);
let err = poll(fast_cfg(), rig, counting_probe(calls.clone()))
.await
.expect_err("auth aborts");
assert!(matches!(err, CoreError::Auth { status: 401, .. }));
assert_eq!(*calls.lock().unwrap(), 1, "no retry on auth");
}
#[tokio::test]
async fn other_errors_abort_immediately() {
let (rig, calls) = counted_rig(vec![Step::NotFound, Step::Done(1)]);
let err = poll(fast_cfg(), rig, counting_probe(calls.clone()))
.await
.expect_err("not-found aborts");
assert!(matches!(err, CoreError::NotFound { .. }));
assert_eq!(*calls.lock().unwrap(), 1);
}
#[tokio::test]
async fn deadline_expiry_is_network_class_with_observation() {
let calls = Mutex::new(0usize);
let err = poll(
PollConfig {
subject: "test readiness".into(),
interval: Duration::from_millis(1),
deadline: Duration::from_millis(20),
..PollConfig::default()
},
&mut (),
|()| {
Box::pin(async {
*calls.lock().unwrap() += 1;
Ok(PollState::<()>::Pending(Some("obs-42".into())))
})
},
)
.await
.expect_err("deadline must expire");
assert!(
matches!(&err, CoreError::Network { source: None, .. }),
"deadline = Network with no transport source: {err}"
);
assert_eq!(err.exit_code(), 4);
assert_eq!(err.code(), "network_error");
let message = err.to_string();
assert!(
message.contains("test readiness"),
"subject named: {message}"
);
assert!(
message.contains("obs-42"),
"last observation carried: {message}"
);
assert!(message.contains("timed out"), "timeout named: {message}");
assert!(
!message.contains("unreachable"),
"an OBSERVED answer is never called unreachable (09-07): {message}"
);
assert!(
message.contains("no terminal state"),
"the observation-bearing lead: {message}"
);
assert!(*calls.lock().unwrap() > 1, "multiple polls before expiry");
}
#[tokio::test]
async fn deadline_without_observation_still_says_unreachable() {
let err = poll(
PollConfig {
subject: "silent wait".into(),
interval: Duration::from_millis(1),
deadline: Duration::from_millis(20),
..PollConfig::default()
},
&mut (),
|()| Box::pin(async { Ok(PollState::<()>::Pending(None)) }),
)
.await
.expect_err("deadline must expire");
let message = err.to_string();
assert!(
message.starts_with("gateway unreachable at silent wait"),
"the no-observation wording preserved: {message}"
);
assert!(message.contains("timed out"), "timeout named: {message}");
assert!(
!message.contains("last observation"),
"no observation to carry: {message}"
);
}
#[test]
fn backoff_sequence_math() {
let floor = Duration::from_secs(2);
let ceiling = Duration::from_secs(30);
let mut current = floor;
let mut sequence = Vec::new();
for _ in 0..12 {
sequence.push(current);
current = next_interval(current, floor, ceiling);
}
assert_eq!(
sequence,
vec![
Duration::from_secs(2),
Duration::from_secs(3),
Duration::from_secs_f64(4.5),
Duration::from_secs_f64(6.75),
Duration::from_secs_f64(10.125),
Duration::from_secs_f64(15.1875),
Duration::from_secs_f64(22.781_25),
Duration::from_secs(30), Duration::from_secs(30),
Duration::from_secs(30),
Duration::from_secs(30),
Duration::from_secs(30),
],
"×1.5 growth clamped to [interval, 30 s]"
);
let tight = next_interval(
Duration::from_secs(3),
Duration::from_secs(2),
Duration::from_secs(4),
);
assert_eq!(tight, Duration::from_secs(4));
let floored = next_interval(
Duration::from_secs(2),
Duration::from_secs(2),
Duration::from_secs(4),
);
assert_eq!(floored, Duration::from_secs(3), "3.0 s — floor unchanged");
}
}