stripe_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 Stripe 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    /// Run it once with a given idempotency key.
14    Idempotent(IdempotencyKey),
15    /// This strategy will retry the request up to the
16    /// specified number of times using the same, random,
17    /// idempotency key, up to n times.
18    Retry(u32),
19    /// This strategy will retry the request up to the
20    /// specified number of times using the same, random,
21    /// idempotency key with exponential backoff, up to n times.
22    ExponentialBackoff(u32),
23}
24
25impl RequestStrategy {
26    /// Decide if we should retry this request, or stop.
27    pub fn test(
28        &self,
29        // Status would be much better as a newtype, but we want to be library
30        // agnostic. We could reimplement our own `StripeStatusCode`, but that
31        // feels a bit like reinventing the wheel...
32        status: Option<u16>,
33        stripe_should_retry: Option<bool>,
34        retry_count: u32,
35    ) -> Outcome {
36        // if stripe explicitly says not to retry then don't
37        if !stripe_should_retry.unwrap_or(true) {
38            return Outcome::Stop;
39        }
40
41        use RequestStrategy::*;
42
43        match (self, status, retry_count) {
44            // a strategy of once or idempotent should run once
45            (Once | Idempotent(_), _, 0) => Outcome::Continue(None),
46
47            // requests with idempotency keys that hit client
48            // errors usually cannot be solved with retries
49            // see: https://stripe.com/docs/error-handling#content-errors
50            (_, Some(c), _) if is_status_client_error(c) => Outcome::Stop,
51
52            // a strategy of retry or exponential backoff should retry with
53            // the appropriate delay if the number of retries is less than the max
54            (Retry(n), _, x) if x < *n => Outcome::Continue(None),
55            (ExponentialBackoff(n), _, x) if x < *n => {
56                Outcome::Continue(Some(calculate_backoff(x)))
57            }
58
59            // unknown cases should be stopped to prevent infinite loops
60            _ => Outcome::Stop,
61        }
62    }
63
64    /// Send the request once with a generated UUID.
65    #[cfg(feature = "uuid")]
66    pub fn idempotent_with_uuid() -> Self {
67        Self::Idempotent(IdempotencyKey::new_uuid_v4())
68    }
69
70    /// Extract the current idempotency key to use for the next request, if any.
71    pub fn get_key(&self) -> Option<IdempotencyKey> {
72        match self {
73            RequestStrategy::Once => None,
74            RequestStrategy::Idempotent(key) => Some(key.clone()),
75            #[cfg(feature = "uuid")]
76            RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => {
77                Some(IdempotencyKey::new_uuid_v4())
78            }
79            #[cfg(not(feature = "uuid"))]
80            RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => None,
81        }
82    }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[repr(transparent)]
87/// Represents valid idempotency key
88/// - Cannot be empty
89/// - Cannot be longer than 255 charachters
90pub struct IdempotencyKey(String);
91
92#[derive(Debug, thiserror::Error)]
93/// Error that can be returned when constructing [`IdempotencyKey`]
94pub enum IdempotentKeyError {
95    #[error("Idempotency Key cannot be empty")]
96    /// Idempotency key cannot be empty
97    EmptyKey,
98    #[error("Idempotency key cannot be longer than 255 characters (you supplied: {0})")]
99    /// Idempotency key cannot be longer than 255 characters
100    KeyTooLong(usize),
101}
102
103impl IdempotencyKey {
104    /// Creates new validated idempotency key.
105    ///
106    /// # Errors
107    /// This function returns error when they key is empty or when
108    /// its longer than 255 characters
109    pub fn new(val: impl AsRef<str>) -> Result<Self, IdempotentKeyError> {
110        let val = val.as_ref();
111        if val.is_empty() {
112            Err(IdempotentKeyError::EmptyKey)
113        } else if val.len() > 255 {
114            Err(IdempotentKeyError::KeyTooLong(val.len()))
115        } else {
116            Ok(Self(val.to_owned()))
117        }
118    }
119
120    #[cfg(feature = "uuid")]
121    /// Generates new UUID as new idempotency key
122    pub fn new_uuid_v4() -> Self {
123        let uuid = uuid::Uuid::new_v4().to_string();
124        Self(uuid)
125    }
126
127    /// Borrows self as string slice
128    pub fn as_str(&self) -> &str {
129        &self.0
130    }
131
132    /// Consumes self and returns inner string
133    pub fn into_inner(self) -> String {
134        self.0
135    }
136}
137
138impl TryFrom<String> for IdempotencyKey {
139    type Error = IdempotentKeyError;
140
141    fn try_from(value: String) -> Result<Self, Self::Error> {
142        Self::new(value)
143    }
144}
145
146fn calculate_backoff(retry_count: u32) -> Duration {
147    Duration::from_secs(2_u64.pow(retry_count))
148}
149
150/// Representation of whether to retry the API request, including a potential waiting period.
151#[derive(PartialEq, Eq, Debug)]
152pub enum Outcome {
153    /// Do not retry the requests, return the last error.
154    Stop,
155    /// Send another request, either immediately or after sleeping for the given `Duration`
156    /// if provided.
157    Continue(Option<Duration>),
158}
159
160#[cfg(test)]
161mod tests {
162    use std::time::Duration;
163
164    use super::{Outcome, RequestStrategy};
165    use crate::IdempotencyKey;
166
167    #[test]
168    fn test_idempotent_strategy() {
169        let key: IdempotencyKey = "key".to_string().try_into().unwrap();
170        let strategy = RequestStrategy::Idempotent(key.clone());
171        assert_eq!(strategy.get_key(), Some(key));
172    }
173
174    #[test]
175    fn test_once_strategy() {
176        let strategy = RequestStrategy::Once;
177        assert_eq!(strategy.get_key(), None);
178        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(None));
179        assert_eq!(strategy.test(None, None, 1), Outcome::Stop);
180    }
181
182    #[test]
183    #[cfg(feature = "uuid")]
184    fn test_uuid_idempotency() {
185        use uuid::Uuid;
186        let strategy = RequestStrategy::Retry(3);
187        assert!(Uuid::parse_str(&strategy.get_key().unwrap().as_str()).is_ok());
188    }
189
190    #[test]
191    #[cfg(not(feature = "uuid"))]
192    fn test_uuid_idempotency() {
193        let strategy = RequestStrategy::Retry(3);
194        assert_eq!(strategy.get_key(), None);
195    }
196
197    #[test]
198    fn test_retry_strategy() {
199        let strategy = RequestStrategy::Retry(3);
200        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(None));
201        assert_eq!(strategy.test(None, None, 1), Outcome::Continue(None));
202        assert_eq!(strategy.test(None, None, 2), Outcome::Continue(None));
203        assert_eq!(strategy.test(None, None, 3), Outcome::Stop);
204        assert_eq!(strategy.test(None, None, 4), Outcome::Stop);
205    }
206
207    #[test]
208    fn test_backoff_strategy() {
209        let strategy = RequestStrategy::ExponentialBackoff(3);
210        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(Some(Duration::from_secs(1))));
211        assert_eq!(strategy.test(None, None, 1), Outcome::Continue(Some(Duration::from_secs(2))));
212        assert_eq!(strategy.test(None, None, 2), Outcome::Continue(Some(Duration::from_secs(4))));
213        assert_eq!(strategy.test(None, None, 3), Outcome::Stop);
214        assert_eq!(strategy.test(None, None, 4), Outcome::Stop);
215    }
216
217    #[test]
218    fn test_retry_header() {
219        let strategy = RequestStrategy::Retry(3);
220        assert_eq!(strategy.test(None, Some(false), 0), Outcome::Stop);
221    }
222}