payjp_client_core/
request_strategy.rs

1use std::time::Duration;
2
3fn is_status_client_error(status: u16) -> bool {
4    (400..500).contains(&status)
5}
6
7/// Possible strategies for sending API requests, including retry behavior
8/// and use of idempotency keys.
9#[derive(Clone, Debug)]
10pub enum RequestStrategy {
11    /// Run the request once.
12    Once,
13    /// This strategy will retry the request up to the
14    /// specified number of times using the same, random,
15    /// idempotency key, up to n times.
16    Retry(u32),
17    /// This strategy will retry the request up to the
18    /// specified number of times using the same, random,
19    /// idempotency key with exponential backoff, up to n times.
20    ExponentialBackoff(u32),
21}
22
23impl RequestStrategy {
24    /// Decide if we should retry this request, or stop.
25    pub fn test(
26        &self,
27        // Status would be much better as a newtype, but we want to be library
28        // agnostic. We could reimplement our own `StatusCode`, but that
29        // feels a bit like reinventing the wheel...
30        status: Option<u16>,
31        retry_count: u32,
32    ) -> Outcome {
33        use RequestStrategy::*;
34
35        match (self, status, retry_count) {
36            // a strategy of once should run once
37            (Once, _, 0) => Outcome::Continue(None),
38
39            // requests with idempotency keys that hit client
40            // errors usually cannot be solved with retries
41            (_, Some(c), _) if is_status_client_error(c) => Outcome::Stop,
42
43            // a strategy of retry or exponential backoff should retry with
44            // the appropriate delay if the number of retries is less than the max
45            (Retry(n), _, x) if x < *n => Outcome::Continue(None),
46            (ExponentialBackoff(n), _, x) if x < *n => {
47                Outcome::Continue(Some(calculate_backoff(x)))
48            }
49
50            // unknown cases should be stopped to prevent infinite loops
51            _ => Outcome::Stop,
52        }
53    }
54
55    /// Extract the current idempotency key to use for the next request, if any.
56    pub fn get_key(&self) -> Option<String> {
57        match self {
58            RequestStrategy::Once => None,
59            #[cfg(feature = "uuid")]
60            RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => {
61                Some(uuid::Uuid::new_v4().to_string())
62            }
63            #[cfg(not(feature = "uuid"))]
64            RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => None,
65        }
66    }
67}
68
69fn calculate_backoff(retry_count: u32) -> Duration {
70    Duration::from_secs(2_u64.pow(retry_count))
71}
72
73/// Representation of whether to retry the API request, including a potential waiting period.
74#[derive(PartialEq, Eq, Debug)]
75pub enum Outcome {
76    /// Do not retry the requests, return the last error.
77    Stop,
78    /// Send another request, either immediately or after sleeping for the given `Duration`
79    /// if provided.
80    Continue(Option<Duration>),
81}
82
83#[cfg(test)]
84mod tests {
85    use std::time::Duration;
86
87    use super::{Outcome, RequestStrategy};
88
89    #[test]
90    fn test_once_strategy() {
91        let strategy = RequestStrategy::Once;
92        assert_eq!(strategy.get_key(), None);
93        assert_eq!(strategy.test(None, 0), Outcome::Continue(None));
94        assert_eq!(strategy.test(None, 1), Outcome::Stop);
95    }
96
97    #[test]
98    #[cfg(feature = "uuid")]
99    fn test_uuid_idempotency() {
100        use uuid::Uuid;
101        let strategy = RequestStrategy::Retry(3);
102        assert!(Uuid::parse_str(&strategy.get_key().unwrap()).is_ok());
103    }
104
105    #[test]
106    #[cfg(not(feature = "uuid"))]
107    fn test_uuid_idempotency() {
108        let strategy = RequestStrategy::Retry(3);
109        assert_eq!(strategy.get_key(), None);
110    }
111
112    #[test]
113    fn test_retry_strategy() {
114        let strategy = RequestStrategy::Retry(3);
115        assert_eq!(strategy.test(None, 0), Outcome::Continue(None));
116        assert_eq!(strategy.test(None, 1), Outcome::Continue(None));
117        assert_eq!(strategy.test(None, 2), Outcome::Continue(None));
118        assert_eq!(strategy.test(None, 3), Outcome::Stop);
119        assert_eq!(strategy.test(None, 4), Outcome::Stop);
120    }
121
122    #[test]
123    fn test_backoff_strategy() {
124        let strategy = RequestStrategy::ExponentialBackoff(3);
125        assert_eq!(strategy.test(None, 0), Outcome::Continue(Some(Duration::from_secs(1))));
126        assert_eq!(strategy.test(None, 1), Outcome::Continue(Some(Duration::from_secs(2))));
127        assert_eq!(strategy.test(None, 2), Outcome::Continue(Some(Duration::from_secs(4))));
128        assert_eq!(strategy.test(None, 3), Outcome::Stop);
129        assert_eq!(strategy.test(None, 4), Outcome::Stop);
130    }
131
132    #[test]
133    fn test_retry_header() {
134        let strategy = RequestStrategy::Retry(3);
135        assert_eq!(strategy.test(None, 0), Outcome::Stop);
136    }
137}