From 9caf59194147fc40a0e8df07ec9630fdf90d696c Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Sun, 22 Jun 2025 17:24:04 +0200 Subject: [PATCH] Encapsulate retry policy in a type This type can be constructed and updated in a const context and is mostly opaque so we can evolve what the exact policy looks like without API changes. --- examples/provision.rs | 4 +- src/lib.rs | 1 + src/order.rs | 90 ++++++++++++++++++++++++++++++++++++------- tests/pebble.rs | 5 ++- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/examples/provision.rs b/examples/provision.rs index 3425e80..bd24356 100644 --- a/examples/provision.rs +++ b/examples/provision.rs @@ -7,7 +7,7 @@ use tracing::info; use instant_acme::{ Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder, - OrderStatus, + OrderStatus, RetryPolicy, }; #[tokio::main] @@ -81,7 +81,7 @@ async fn main() -> anyhow::Result<()> { // Exponentially back off until the order becomes ready or invalid. - let status = order.poll(5, Duration::from_millis(250)).await?; + let status = order.poll(&RetryPolicy::default()).await?; if status != OrderStatus::Ready { return Err(anyhow::anyhow!("unexpected order status: {status:?}")); } diff --git a/src/lib.rs b/src/lib.rs index 6169a30..de58650 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ pub use account::{Account, ExternalAccountKey}; mod order; pub use order::{ AuthorizationHandle, Authorizations, ChallengeHandle, Identifiers, KeyAuthorization, Order, + RetryPolicy, }; mod types; #[cfg(feature = "time")] diff --git a/src/order.rs b/src/order.rs index 341205a..67a3d7f 100644 --- a/src/order.rs +++ b/src/order.rs @@ -1,4 +1,4 @@ -use std::ops::Deref; +use std::ops::{ControlFlow, Deref}; use std::sync::Arc; use std::time::Duration; use std::{fmt, slice}; @@ -157,28 +157,22 @@ impl Order { } } - /// Poll the order with exponential backoff until in a final state - /// - /// Refresh the order state from the server for `tries` times, waiting `delay` before the - /// first attempt and increasing the delay by a factor of 2 for each subsequent attempt. + /// Poll the order with the given [`RetryPolicy`] /// /// Yields the [`OrderStatus`] immediately if `Ready` or `Invalid`, or after `tries` attempts. - /// - /// (Empirically, we've had good results with 5 tries and an initial delay of 250ms.) - pub async fn poll(&mut self, mut tries: u8, mut delay: Duration) -> Result { + pub async fn poll(&mut self, retries: &RetryPolicy) -> Result { + let mut retrying = retries.state(); loop { - sleep(delay).await; + if let ControlFlow::Break(()) = retrying.wait().await { + return Ok(self.state.status); + } + let state = self.refresh().await?; if let Some(error) = &state.error { return Err(Error::Api(error.clone())); } else if let OrderStatus::Ready | OrderStatus::Invalid = state.status { return Ok(state.status); - } else if tries <= 1 { - return Ok(state.status); } - - delay *= 2; - tries -= 1; } } @@ -491,3 +485,71 @@ impl fmt::Debug for KeyAuthorization { f.debug_tuple("KeyAuthorization").finish() } } + +/// A policy for retrying API requests +/// +/// Refresh the order state from the server for `tries` times, waiting `delay` before the +/// first attempt and increasing the delay by a factor of 2 for each subsequent attempt. +#[derive(Debug, Clone, Copy)] +pub struct RetryPolicy { + tries: u8, + delay: Duration, +} + +impl RetryPolicy { + /// A constructor for the default `RetryPolicy` + /// + /// Will retry 5 times with an initial delay of 250ms. + pub const fn new() -> Self { + Self { + tries: 5, + delay: Duration::from_millis(250), + } + } + + /// Set the initial delay + /// + /// This is the delay before the first retry attempt. The delay will be doubled for each + /// subsequent retry attempt. + pub const fn initial_delay(mut self, delay: Duration) -> Self { + self.delay = delay; + self + } + + /// Set the number of retry attempts + pub const fn tries(mut self, tries: u8) -> Self { + self.tries = tries; + self + } + + fn state(&self) -> RetryState { + RetryState { + tries: self.tries, + delay: self.delay, + } + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self::new() + } +} + +struct RetryState { + tries: u8, + delay: Duration, +} + +impl RetryState { + async fn wait(&mut self) -> ControlFlow<(), ()> { + if self.tries == 0 { + return ControlFlow::Break(()); + } + + sleep(self.delay).await; + self.delay *= 2; + self.tries -= 1; + ControlFlow::Continue(()) + } +} diff --git a/tests/pebble.rs b/tests/pebble.rs index 8337e37..a139d59 100644 --- a/tests/pebble.rs +++ b/tests/pebble.rs @@ -25,7 +25,7 @@ use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::rt::TokioExecutor; use instant_acme::{ Account, AuthorizationStatus, ChallengeHandle, ChallengeType, Error, ExternalAccountKey, - Identifier, KeyAuthorization, NewAccount, NewOrder, Order, OrderStatus, + Identifier, KeyAuthorization, NewAccount, NewOrder, Order, OrderStatus, RetryPolicy, }; #[cfg(all(feature = "time", feature = "x509-parser"))] use instant_acme::{CertificateIdentifier, RevocationRequest}; @@ -530,7 +530,7 @@ impl Environment { } // Poll until the order is ready. - let status = order.poll(10, Duration::from_millis(250)).await?; + let status = order.poll(&RETRY_POLICY).await?; if status != OrderStatus::Ready { return Err(format!("unexpected order status: {status:?}").into()); } @@ -900,3 +900,4 @@ impl Drop for Subprocess { } static NEXT_PORT: AtomicU16 = AtomicU16::new(5555); +const RETRY_POLICY: RetryPolicy = RetryPolicy::new().tries(10);