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
98/// Explicit state for one caller-driven action polling operation.
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub struct ActionPoller {
101    observations: u32,
102    last_progress: Option<u8>,
103    terminal: bool,
104}
105
106impl ActionPoller {
107    /// Creates an action poller without selecting time or retry policy.
108    #[must_use]
109    pub const fn new() -> Self {
110        Self {
111            observations: 0,
112            last_progress: None,
113            terminal: false,
114        }
115    }
116
117    /// Returns the number of accepted observations.
118    #[must_use]
119    pub const fn observations(self) -> u32 {
120        self.observations
121    }
122
123    /// Reports whether polling reached a terminal step.
124    #[must_use]
125    pub const fn is_terminal(self) -> bool {
126        self.terminal
127    }
128
129    /// Records one decoded action response and asks caller policy only when it
130    /// remains running.
131    pub fn observe<E, P>(
132        &mut self,
133        update: ActionUpdate<E>,
134        progress: u8,
135        rate_limit: Option<RateLimit>,
136        policy: &mut P,
137    ) -> Result<ActionPollStep<E>, ActionPollError<P::Error>>
138    where
139        P: PollPolicy,
140    {
141        if self.terminal {
142            return Err(ActionPollError::Terminal);
143        }
144        let observations = self
145            .observations
146            .checked_add(1)
147            .ok_or(ActionPollError::ObservationOverflow)?;
148
149        let step = match update {
150            ActionUpdate::Success => ActionPollStep::Complete,
151            ActionUpdate::Failed(error) => ActionPollStep::Failed(error),
152            ActionUpdate::Running => {
153                if progress > 100 {
154                    return Err(ActionPollError::InvalidProgress);
155                }
156                if self.last_progress.is_some_and(|last| progress < last) {
157                    return Err(ActionPollError::ProgressRegressed);
158                }
159                let context = PollContext {
160                    observation: observations,
161                    progress,
162                    rate_limit,
163                };
164                match policy.decide(context).map_err(ActionPollError::Policy)? {
165                    PollDecision::Delay(delay) if delay.is_zero() => {
166                        return Err(ActionPollError::ZeroDelay);
167                    }
168                    PollDecision::Delay(delay) => ActionPollStep::Delay(delay),
169                    PollDecision::Cancel => ActionPollStep::Cancelled,
170                    PollDecision::Timeout => ActionPollStep::TimedOut,
171                }
172            }
173        };
174
175        self.observations = observations;
176        self.last_progress = Some(progress);
177        self.terminal = !matches!(step, ActionPollStep::Delay(_));
178        Ok(step)
179    }
180}
181
182impl Default for ActionPoller {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188#[cfg(test)]
189mod tests;