stripe_client_core/
request_strategy.rs

1use std::time::Duration;
2
3fn is_status_client_error(status: u16) -> bool {
4    (400..500).contains(&status) && status != 429
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        use RequestStrategy::*;
37
38        // Handle Stripe-Should-Retry header
39        match stripe_should_retry {
40            // If Stripe explicitly says not to retry, never retry
41            Some(false) => return Outcome::Stop,
42            // If Stripe explicitly says to retry, continue with retry logic
43            Some(true) => {}
44            // If header is absent, only retry for 429 (Too Many Requests)
45            None => {
46                if let Some(s) = status {
47                    if s != 429 {
48                        return Outcome::Stop;
49                    }
50                }
51            }
52        }
53
54        match (self, status, retry_count) {
55            // a strategy of once or idempotent should run once
56            (Once | Idempotent(_), _, 0) => Outcome::Continue(None),
57
58            // requests with idempotency keys that hit client
59            // errors usually cannot be solved with retries
60            // see: https://stripe.com/docs/error-handling#content-errors
61            (_, Some(c), _) if is_status_client_error(c) => Outcome::Stop,
62
63            // a strategy of retry or exponential backoff should retry with
64            // the appropriate delay if the number of retries is less than the max
65            (Retry(n), _, x) if x < *n => Outcome::Continue(None),
66            (ExponentialBackoff(n), _, x) if x < *n => {
67                Outcome::Continue(Some(calculate_backoff(x)))
68            }
69
70            // unknown cases should be stopped to prevent infinite loops
71            _ => Outcome::Stop,
72        }
73    }
74
75    /// Send the request once with a generated UUID.
76    #[cfg(feature = "uuid")]
77    pub fn idempotent_with_uuid() -> Self {
78        Self::Idempotent(IdempotencyKey::new_uuid_v4())
79    }
80
81    /// Extract the current idempotency key to use for the next request, if any.
82    pub fn get_key(&self) -> Option<IdempotencyKey> {
83        match self {
84            RequestStrategy::Once => None,
85            RequestStrategy::Idempotent(key) => Some(key.clone()),
86            #[cfg(feature = "uuid")]
87            RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => {
88                Some(IdempotencyKey::new_uuid_v4())
89            }
90            #[cfg(not(feature = "uuid"))]
91            RequestStrategy::Retry(_) | RequestStrategy::ExponentialBackoff(_) => None,
92        }
93    }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97#[repr(transparent)]
98/// Represents valid idempotency key
99/// - Cannot be empty
100/// - Cannot be longer than 255 charachters
101pub struct IdempotencyKey(String);
102
103#[derive(Debug, thiserror::Error)]
104/// Error that can be returned when constructing [`IdempotencyKey`]
105pub enum IdempotentKeyError {
106    #[error("Idempotency Key cannot be empty")]
107    /// Idempotency key cannot be empty
108    EmptyKey,
109    #[error("Idempotency key cannot be longer than 255 characters (you supplied: {0})")]
110    /// Idempotency key cannot be longer than 255 characters
111    KeyTooLong(usize),
112}
113
114impl IdempotencyKey {
115    /// Creates new validated idempotency key.
116    ///
117    /// # Errors
118    /// This function returns error when they key is empty or when
119    /// its longer than 255 characters
120    pub fn new(val: impl AsRef<str>) -> Result<Self, IdempotentKeyError> {
121        let val = val.as_ref();
122        if val.is_empty() {
123            Err(IdempotentKeyError::EmptyKey)
124        } else if val.len() > 255 {
125            Err(IdempotentKeyError::KeyTooLong(val.len()))
126        } else {
127            Ok(Self(val.to_owned()))
128        }
129    }
130
131    #[cfg(feature = "uuid")]
132    /// Generates new UUID as new idempotency key
133    pub fn new_uuid_v4() -> Self {
134        let uuid = uuid::Uuid::new_v4().to_string();
135        Self(uuid)
136    }
137
138    /// Borrows self as string slice
139    pub fn as_str(&self) -> &str {
140        &self.0
141    }
142
143    /// Consumes self and returns inner string
144    pub fn into_inner(self) -> String {
145        self.0
146    }
147}
148
149impl TryFrom<String> for IdempotencyKey {
150    type Error = IdempotentKeyError;
151
152    fn try_from(value: String) -> Result<Self, Self::Error> {
153        Self::new(value)
154    }
155}
156
157#[cfg(feature = "uuid")]
158impl From<uuid::Uuid> for IdempotencyKey {
159    #[inline]
160    fn from(value: uuid::Uuid) -> Self {
161        Self(value.to_string())
162    }
163}
164
165fn calculate_backoff(retry_count: u32) -> Duration {
166    Duration::from_secs(2_u64.pow(retry_count))
167}
168
169/// Representation of whether to retry the API request, including a potential waiting period.
170#[derive(PartialEq, Eq, Debug)]
171pub enum Outcome {
172    /// Do not retry the requests, return the last error.
173    Stop,
174    /// Send another request, either immediately or after sleeping for the given `Duration`
175    /// if provided.
176    Continue(Option<Duration>),
177}
178
179#[cfg(test)]
180mod tests {
181    use std::time::Duration;
182
183    use super::{Outcome, RequestStrategy};
184    use crate::IdempotencyKey;
185
186    #[test]
187    fn test_idempotent_strategy() {
188        let key: IdempotencyKey = "key".to_string().try_into().unwrap();
189        let strategy = RequestStrategy::Idempotent(key.clone());
190        assert_eq!(strategy.get_key(), Some(key));
191    }
192
193    #[test]
194    fn test_once_strategy() {
195        let strategy = RequestStrategy::Once;
196        assert_eq!(strategy.get_key(), None);
197        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(None));
198        assert_eq!(strategy.test(None, None, 1), Outcome::Stop);
199    }
200
201    #[test]
202    #[cfg(feature = "uuid")]
203    fn test_uuid_idempotency() {
204        use uuid::Uuid;
205        let strategy = RequestStrategy::Retry(3);
206        assert!(Uuid::parse_str(strategy.get_key().unwrap().as_str()).is_ok());
207    }
208
209    #[test]
210    #[cfg(not(feature = "uuid"))]
211    fn test_uuid_idempotency() {
212        let strategy = RequestStrategy::Retry(3);
213        assert_eq!(strategy.get_key(), None);
214    }
215
216    #[test]
217    fn test_retry_strategy() {
218        let strategy = RequestStrategy::Retry(3);
219        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(None));
220        assert_eq!(strategy.test(None, None, 1), Outcome::Continue(None));
221        assert_eq!(strategy.test(None, None, 2), Outcome::Continue(None));
222        assert_eq!(strategy.test(None, None, 3), Outcome::Stop);
223        assert_eq!(strategy.test(None, None, 4), Outcome::Stop);
224    }
225
226    #[test]
227    fn test_backoff_strategy() {
228        let strategy = RequestStrategy::ExponentialBackoff(3);
229        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(Some(Duration::from_secs(1))));
230        assert_eq!(strategy.test(None, None, 1), Outcome::Continue(Some(Duration::from_secs(2))));
231        assert_eq!(strategy.test(None, None, 2), Outcome::Continue(Some(Duration::from_secs(4))));
232        assert_eq!(strategy.test(None, None, 3), Outcome::Stop);
233        assert_eq!(strategy.test(None, None, 4), Outcome::Stop);
234    }
235
236    #[test]
237    fn test_retry_header() {
238        let strategy = RequestStrategy::Retry(3);
239        assert_eq!(strategy.test(None, Some(false), 0), Outcome::Stop);
240    }
241
242    #[test]
243    fn test_stripe_should_retry_true() {
244        let strategy = RequestStrategy::Retry(3);
245        // When Stripe-Should-Retry is true, should retry regardless of status code
246        assert_eq!(strategy.test(Some(500), Some(true), 0), Outcome::Continue(None));
247        assert_eq!(strategy.test(Some(503), Some(true), 0), Outcome::Continue(None));
248        // Except for client errors (4xx)
249        assert_eq!(strategy.test(Some(400), Some(true), 0), Outcome::Stop);
250        assert_eq!(strategy.test(Some(404), Some(true), 0), Outcome::Stop);
251    }
252
253    #[test]
254    fn test_stripe_should_retry_false() {
255        let strategy = RequestStrategy::Retry(3);
256        // When Stripe-Should-Retry is false, never retry
257        assert_eq!(strategy.test(Some(429), Some(false), 0), Outcome::Stop);
258        assert_eq!(strategy.test(Some(500), Some(false), 0), Outcome::Stop);
259        assert_eq!(strategy.test(Some(200), Some(false), 0), Outcome::Stop);
260    }
261
262    #[test]
263    fn test_stripe_should_retry_absent_429() {
264        let strategy = RequestStrategy::Retry(3);
265        // When header is absent and status is 429, should retry
266        assert_eq!(strategy.test(Some(429), None, 0), Outcome::Continue(None));
267        assert_eq!(strategy.test(Some(429), None, 1), Outcome::Continue(None));
268    }
269
270    #[test]
271    fn test_stripe_should_retry_absent_500() {
272        let strategy = RequestStrategy::Retry(3);
273        // When header is absent and status is 500, should NOT retry
274        assert_eq!(strategy.test(Some(500), None, 0), Outcome::Stop);
275        assert_eq!(strategy.test(Some(503), None, 0), Outcome::Stop);
276    }
277
278    #[test]
279    fn test_stripe_should_retry_absent_4xx() {
280        let strategy = RequestStrategy::Retry(3);
281        // When header is absent and status is 4xx, should NOT retry
282        assert_eq!(strategy.test(Some(400), None, 0), Outcome::Stop);
283        assert_eq!(strategy.test(Some(404), None, 0), Outcome::Stop);
284    }
285
286    #[test]
287    fn test_backoff_with_stripe_should_retry() {
288        let strategy = RequestStrategy::ExponentialBackoff(3);
289        // Test that exponential backoff works with Stripe-Should-Retry=true
290        assert_eq!(
291            strategy.test(Some(500), Some(true), 0),
292            Outcome::Continue(Some(Duration::from_secs(1)))
293        );
294        assert_eq!(
295            strategy.test(Some(500), Some(true), 1),
296            Outcome::Continue(Some(Duration::from_secs(2)))
297        );
298        assert_eq!(
299            strategy.test(Some(500), Some(true), 2),
300            Outcome::Continue(Some(Duration::from_secs(4)))
301        );
302        assert_eq!(strategy.test(Some(500), Some(true), 3), Outcome::Stop);
303    }
304
305    #[test]
306    fn test_backoff_with_429_no_header() {
307        let strategy = RequestStrategy::ExponentialBackoff(3);
308        // Test that exponential backoff works with 429 when header is absent
309        assert_eq!(
310            strategy.test(Some(429), None, 0),
311            Outcome::Continue(Some(Duration::from_secs(1)))
312        );
313        assert_eq!(
314            strategy.test(Some(429), None, 1),
315            Outcome::Continue(Some(Duration::from_secs(2)))
316        );
317    }
318}