Skip to main content

cloud_sdk/
action_polling.rs

1//! Caller-driven action polling state machine.
2
3use core::time::Duration;
4
5use crate::rate_limit::RateLimit;
6
7/// One provider action observation.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ActionUpdate<E> {
10    /// The action remains in progress.
11    Running,
12    /// The action completed successfully.
13    Success,
14    /// The action completed with a provider failure.
15    Failed(E),
16}
17
18/// Context passed to caller-owned delay and stop policy.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct PollContext {
21    observation: u32,
22    progress: u8,
23    rate_limit: Option<RateLimit>,
24}
25
26impl PollContext {
27    /// Returns the one-based running observation count.
28    #[must_use]
29    pub const fn observation(self) -> u32 {
30        self.observation
31    }
32
33    /// Returns provider-reported progress in `0..=100`.
34    #[must_use]
35    pub const fn progress(self) -> u8 {
36        self.progress
37    }
38
39    /// Returns rate-limit metadata associated with the observation.
40    #[must_use]
41    pub const fn rate_limit(self) -> Option<RateLimit> {
42        self.rate_limit
43    }
44}
45
46/// Caller-owned decision after a running action observation.
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub enum PollDecision {
49    /// Wait for this nonzero duration before the caller sends another request.
50    Delay(Duration),
51    /// Stop because caller cancellation was requested.
52    Cancel,
53    /// Stop because the caller's deadline or attempt budget expired.
54    Timeout,
55}
56
57/// Caller-supplied delay, backoff, timeout, and cancellation policy.
58pub trait PollPolicy {
59    /// Policy-specific error.
60    type Error;
61
62    /// Chooses the next explicit step after a running observation.
63    fn decide(&mut self, context: PollContext) -> Result<PollDecision, Self::Error>;
64}
65
66/// Step returned after one accepted observation.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ActionPollStep<E> {
69    /// The caller must wait before requesting the action again.
70    Delay(Duration),
71    /// The action completed successfully.
72    Complete,
73    /// The provider reported a terminal action failure.
74    Failed(E),
75    /// Caller policy cancelled polling.
76    Cancelled,
77    /// Caller policy stopped polling at its deadline or attempt budget.
78    TimedOut,
79}
80
81/// Action observation or policy failure.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum ActionPollError<E> {
84    /// Running provider progress exceeded 100.
85    InvalidProgress,
86    /// Running progress moved backwards across observations.
87    ProgressRegressed,
88    /// A caller policy requested a zero-duration busy loop.
89    ZeroDelay,
90    /// The observation counter overflowed.
91    ObservationOverflow,
92    /// Polling was attempted after a terminal step.
93    Terminal,
94    /// Caller policy failed.
95    Policy(E),
96}
97
98impl<E> core::fmt::Display for ActionPollError<E> {
99    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
100        formatter.write_str(match self {
101            Self::InvalidProgress => "action progress exceeds 100",
102            Self::ProgressRegressed => "action progress moved backwards",
103            Self::ZeroDelay => "action poll policy requested a zero delay",
104            Self::ObservationOverflow => "action observation counter overflowed",
105            Self::Terminal => "action polling already reached a terminal state",
106            Self::Policy(_) => "action poll policy failed",
107        })
108    }
109}
110
111impl<E: core::fmt::Debug> core::error::Error for ActionPollError<E> {}
112
113/// Explicit state for one caller-driven action polling operation.
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub struct ActionPoller {
116    observations: u32,
117    last_progress: Option<u8>,
118    terminal: bool,
119}
120
121impl ActionPoller {
122    /// Creates an action poller without selecting time or retry policy.
123    #[must_use]
124    pub const fn new() -> Self {
125        Self {
126            observations: 0,
127            last_progress: None,
128            terminal: false,
129        }
130    }
131
132    /// Returns the number of accepted observations.
133    #[must_use]
134    pub const fn observations(self) -> u32 {
135        self.observations
136    }
137
138    /// Reports whether polling reached a terminal step.
139    #[must_use]
140    pub const fn is_terminal(self) -> bool {
141        self.terminal
142    }
143
144    /// Records one decoded action response and asks caller policy only when it
145    /// remains running.
146    pub fn observe<E, P>(
147        &mut self,
148        update: ActionUpdate<E>,
149        progress: u8,
150        rate_limit: Option<RateLimit>,
151        policy: &mut P,
152    ) -> Result<ActionPollStep<E>, ActionPollError<P::Error>>
153    where
154        P: PollPolicy,
155    {
156        if self.terminal {
157            return Err(ActionPollError::Terminal);
158        }
159        let observations = self
160            .observations
161            .checked_add(1)
162            .ok_or(ActionPollError::ObservationOverflow)?;
163
164        let step = match update {
165            ActionUpdate::Success => ActionPollStep::Complete,
166            ActionUpdate::Failed(error) => ActionPollStep::Failed(error),
167            ActionUpdate::Running => {
168                if progress > 100 {
169                    return Err(ActionPollError::InvalidProgress);
170                }
171                if self.last_progress.is_some_and(|last| progress < last) {
172                    return Err(ActionPollError::ProgressRegressed);
173                }
174                let context = PollContext {
175                    observation: observations,
176                    progress,
177                    rate_limit,
178                };
179                match policy.decide(context).map_err(ActionPollError::Policy)? {
180                    PollDecision::Delay(delay) if delay.is_zero() => {
181                        return Err(ActionPollError::ZeroDelay);
182                    }
183                    PollDecision::Delay(delay) => ActionPollStep::Delay(delay),
184                    PollDecision::Cancel => ActionPollStep::Cancelled,
185                    PollDecision::Timeout => ActionPollStep::TimedOut,
186                }
187            }
188        };
189
190        self.observations = observations;
191        self.last_progress = Some(progress);
192        self.terminal = !matches!(step, ActionPollStep::Delay(_));
193        Ok(step)
194    }
195}
196
197impl Default for ActionPoller {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203#[cfg(test)]
204mod tests;