Skip to main content

execution_policy/
builder.rs

1//! Fluent builder for [`ExecutionPolicy`].
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::breaker::CircuitBreaker;
7use crate::concurrency::ConcurrencyLimit;
8use crate::core::Core;
9#[cfg(feature = "tokio")]
10use crate::core::DefaultCore;
11use crate::event::{Event, EventHook};
12use crate::plan::{CompiledBreaker, Plan};
13use crate::policy::ExecutionPolicy;
14use crate::retry::Retry;
15
16/// Returned by [`ExecutionPolicyBuilder::try_build`] on invalid config.
17#[derive(Debug, Clone)]
18pub struct BuildError(pub(crate) String);
19
20impl std::fmt::Display for BuildError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "invalid execution policy: {}", self.0)
23    }
24}
25impl std::error::Error for BuildError {}
26
27/// Fluent builder for an [`ExecutionPolicy`].
28pub struct ExecutionPolicyBuilder<T, E> {
29    retry: Option<Retry<T, E>>,
30    attempt_timeout: Option<Duration>,
31    total_timeout: Option<Duration>,
32    breaker: Option<CircuitBreaker<E>>,
33    concurrency: Option<ConcurrencyLimit>,
34    on_event: Option<EventHook>,
35}
36
37impl<T, E> std::fmt::Debug for ExecutionPolicyBuilder<T, E> {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("ExecutionPolicyBuilder")
40            .field("retry", &self.retry)
41            .field("attempt_timeout", &self.attempt_timeout)
42            .field("total_timeout", &self.total_timeout)
43            .field("breaker", &self.breaker)
44            .field("concurrency", &self.concurrency)
45            .field("on_event", &self.on_event.as_ref().map(|_| "<fn>"))
46            .finish()
47    }
48}
49
50impl<T, E> Default for ExecutionPolicyBuilder<T, E> {
51    fn default() -> Self {
52        Self {
53            retry: None,
54            attempt_timeout: None,
55            total_timeout: None,
56            breaker: None,
57            concurrency: None,
58            on_event: None,
59        }
60    }
61}
62
63impl<T, E> ExecutionPolicyBuilder<T, E> {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    pub fn retry(mut self, retry: Retry<T, E>) -> Self {
69        debug_assert!(self.retry.is_none(), "retry configured twice (last-wins)");
70        self.retry = Some(retry);
71        self
72    }
73    pub fn attempt_timeout(mut self, d: Duration) -> Self {
74        self.attempt_timeout = Some(d);
75        self
76    }
77    pub fn total_timeout(mut self, d: Duration) -> Self {
78        self.total_timeout = Some(d);
79        self
80    }
81    pub fn circuit_breaker(mut self, breaker: CircuitBreaker<E>) -> Self {
82        debug_assert!(
83            self.breaker.is_none(),
84            "circuit_breaker configured twice (last-wins)"
85        );
86        self.breaker = Some(breaker);
87        self
88    }
89    pub fn concurrency_limit(mut self, limit: impl Into<ConcurrencyLimit>) -> Self {
90        debug_assert!(
91            self.concurrency.is_none(),
92            "concurrency_limit configured twice (last-wins)"
93        );
94        self.concurrency = Some(limit.into());
95        self
96    }
97    /// Register a synchronous, cheap event hook. Not `catch_unwind`-wrapped — a
98    /// panicking hook surfaces as a bug (fail fast).
99    pub fn on_event(mut self, hook: impl Fn(&Event) + Send + Sync + 'static) -> Self {
100        self.on_event = Some(Arc::new(hook));
101        self
102    }
103    /// Bridge events to `tracing` at debug level.
104    #[cfg(feature = "tracing")]
105    pub fn with_tracing(self) -> Self {
106        self.on_event(|e: &Event| tracing::debug!(event = ?e, "execution-policy"))
107    }
108
109    fn validate(&self) -> Result<(), BuildError> {
110        if let Some(r) = &self.retry {
111            if r.max_attempts_value() == 0 {
112                return Err(BuildError("max_attempts must be >= 1".into()));
113            }
114        }
115        if let (Some(a), Some(t)) = (self.attempt_timeout, self.total_timeout) {
116            if a > t {
117                return Err(BuildError(format!(
118                    "attempt_timeout ({a:?}) must be <= total_timeout ({t:?})"
119                )));
120            }
121        }
122        Ok(())
123    }
124
125    fn compile(self) -> Plan<T, E> {
126        let breaker = self.breaker.map(|b| {
127            let (runtime, record_when) = b.compile();
128            CompiledBreaker {
129                runtime: std::sync::Arc::new(runtime),
130                record_when,
131            }
132        });
133        let concurrency = self.concurrency.as_ref().map(|c| c.compile());
134        Plan {
135            retry: self.retry.unwrap_or_else(Retry::none),
136            attempt_timeout: self.attempt_timeout,
137            total_timeout: self.total_timeout,
138            breaker,
139            concurrency,
140            on_event: self.on_event,
141        }
142    }
143
144    /// Validate and build with the default core. Panics on invalid config.
145    #[cfg(feature = "tokio")]
146    pub fn build(self) -> ExecutionPolicy<DefaultCore, T, E> {
147        self.build_with(DefaultCore::new())
148    }
149
150    /// Validate and build with the default core, returning an error on bad config.
151    #[cfg(feature = "tokio")]
152    pub fn try_build(self) -> Result<ExecutionPolicy<DefaultCore, T, E>, BuildError> {
153        self.validate()?;
154        Ok(ExecutionPolicy::from_parts(
155            DefaultCore::new(),
156            Arc::new(self.compile()),
157        ))
158    }
159
160    /// Validate and build with a custom [`Core`]. Panics on invalid config.
161    pub fn build_with<C: Core>(self, core: C) -> ExecutionPolicy<C, T, E> {
162        if let Err(e) = self.validate() {
163            panic!("{e}");
164        }
165        ExecutionPolicy::from_parts(core, Arc::new(self.compile()))
166    }
167}
168
169#[cfg(all(test, feature = "test-util"))]
170mod tests {
171    use super::*;
172    use crate::core::{ManualClock, TestCore};
173
174    #[test]
175    #[should_panic(expected = "max_attempts must be >= 1")]
176    fn build_panics_on_zero_attempts() {
177        let _ = ExecutionPolicyBuilder::<u32, &str>::new()
178            .retry(Retry::exponential().max_attempts(0))
179            .build_with(TestCore::new(ManualClock::new()));
180    }
181
182    #[test]
183    fn try_build_reports_timeout_inversion() {
184        let err = ExecutionPolicyBuilder::<u32, &str>::new()
185            .attempt_timeout(Duration::from_secs(5))
186            .total_timeout(Duration::from_secs(2))
187            .validate();
188        assert!(err.is_err());
189    }
190}