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
146#[cfg(feature = "uuid")]
147impl From<uuid::Uuid> for IdempotencyKey {
148    #[inline]
149    fn from(value: uuid::Uuid) -> Self {
150        Self(value.to_string())
151    }
152}
153
154fn calculate_backoff(retry_count: u32) -> Duration {
155    Duration::from_secs(2_u64.pow(retry_count))
156}
157
158/// Representation of whether to retry the API request, including a potential waiting period.
159#[derive(PartialEq, Eq, Debug)]
160pub enum Outcome {
161    /// Do not retry the requests, return the last error.
162    Stop,
163    /// Send another request, either immediately or after sleeping for the given `Duration`
164    /// if provided.
165    Continue(Option<Duration>),
166}
167
168#[cfg(test)]
169mod tests {
170    use std::time::Duration;
171
172    use super::{Outcome, RequestStrategy};
173    use crate::IdempotencyKey;
174
175    #[test]
176    fn test_idempotent_strategy() {
177        let key: IdempotencyKey = "key".to_string().try_into().unwrap();
178        let strategy = RequestStrategy::Idempotent(key.clone());
179        assert_eq!(strategy.get_key(), Some(key));
180    }
181
182    #[test]
183    fn test_once_strategy() {
184        let strategy = RequestStrategy::Once;
185        assert_eq!(strategy.get_key(), None);
186        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(None));
187        assert_eq!(strategy.test(None, None, 1), Outcome::Stop);
188    }
189
190    #[test]
191    #[cfg(feature = "uuid")]
192    fn test_uuid_idempotency() {
193        use uuid::Uuid;
194        let strategy = RequestStrategy::Retry(3);
195        assert!(Uuid::parse_str(&strategy.get_key().unwrap().as_str()).is_ok());
196    }
197
198    #[test]
199    #[cfg(not(feature = "uuid"))]
200    fn test_uuid_idempotency() {
201        let strategy = RequestStrategy::Retry(3);
202        assert_eq!(strategy.get_key(), None);
203    }
204
205    #[test]
206    fn test_retry_strategy() {
207        let strategy = RequestStrategy::Retry(3);
208        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(None));
209        assert_eq!(strategy.test(None, None, 1), Outcome::Continue(None));
210        assert_eq!(strategy.test(None, None, 2), Outcome::Continue(None));
211        assert_eq!(strategy.test(None, None, 3), Outcome::Stop);
212        assert_eq!(strategy.test(None, None, 4), Outcome::Stop);
213    }
214
215    #[test]
216    fn test_backoff_strategy() {
217        let strategy = RequestStrategy::ExponentialBackoff(3);
218        assert_eq!(strategy.test(None, None, 0), Outcome::Continue(Some(Duration::from_secs(1))));
219        assert_eq!(strategy.test(None, None, 1), Outcome::Continue(Some(Duration::from_secs(2))));
220        assert_eq!(strategy.test(None, None, 2), Outcome::Continue(Some(Duration::from_secs(4))));
221        assert_eq!(strategy.test(None, None, 3), Outcome::Stop);
222        assert_eq!(strategy.test(None, None, 4), Outcome::Stop);
223    }
224
225    #[test]
226    fn test_retry_header() {
227        let strategy = RequestStrategy::Retry(3);
228        assert_eq!(strategy.test(None, Some(false), 0), Outcome::Stop);
229    }
230}