diff --git a/CHANGELOG.md b/CHANGELOG.md index 030c36e..c39b303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Versioning]. ## [Unreleased] -* Automatically retry HTTP requests on status code 429. +* Automatically retry HTTP requests that return status code 429. (too many requests) ## [0.11.0] - 2024-03-29 diff --git a/Cargo.toml b/Cargo.toml index 610273a..b644eed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ tokio = { version = "1.23.0", features = ["macros"] } tokio-stream = "0.1.11" tracing = "0.1.37" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +wiremock = "0.5.19" [package.metadata.docs.rs] all-features = true diff --git a/src/client.rs b/src/client.rs index ac7efd1..f7e0b15 100644 --- a/src/client.rs +++ b/src/client.rs @@ -66,7 +66,8 @@ impl Client { url.path_segments_mut() .expect("builder validated URL can be a base") .extend(path); - // All request methods and paths are assumed to be retryable. + // All request methods and paths are included to support retries for + // 429 status code. self.client_retryable .request(method, url) .bearer_auth(&self.api_key) diff --git a/src/config.rs b/src/config.rs index 727c9b9..0005b72 100644 --- a/src/config.rs +++ b/src/config.rs @@ -73,7 +73,7 @@ impl RetryableStrategy for Retry429 { impl ClientBuilder { /// Sets the policy for retrying failed API calls. /// - /// Note that the created [`Client`] will retry all API calls. + /// Note that the created [`Client`] will retry all API calls that return a 429 status code. pub fn with_retry_policy(mut self, policy: ExponentialBackoff) -> Self { self.retry_policy = Some(policy); self diff --git a/tests/api.rs b/tests/api.rs index 4482cee..6b745ae 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -36,9 +36,11 @@ use futures::stream::TryStreamExt; use once_cell::sync::Lazy; use rand::Rng; use reqwest::StatusCode; +use reqwest_retry::policies::ExponentialBackoff; use test_log::test; use tokio::time::{self, Duration}; use tracing::info; +use wiremock::{matchers, Mock, MockServer, ResponseTemplate}; use orb_billing::{ AddIncrementCreditLedgerEntryRequestParams, AddVoidCreditLedgerEntryRequestParams, Address, @@ -769,3 +771,45 @@ async fn test_errors() { let res = client.get_customer_by_external_id("$NOEXIST$").await; assert_error_with_status_code(res, StatusCode::NOT_FOUND); } + +// Tests that 429 responses are retried automatically by the client for API calls +#[test(tokio::test)] +async fn test_retry_429() { + // Start a mock orb API server and a client configured to target that + // server. The retry policy disables backoff to speed up the tests. + const MAX_RETRIES: u32 = 3; + let server = MockServer::start().await; + let client = Client::builder() + .with_endpoint(server.uri().parse().unwrap()) + .with_retry_policy( + ExponentialBackoff::builder() + .retry_bounds(Duration::from_millis(1), Duration::from_millis(1)) + .build_with_max_retries(MAX_RETRIES), + ) + .build(ClientConfig { api_key: "".into() }); + + // register a mock for the /customers endpoint that returns a 429 response + // code. Ensure the client repeatedly retries the API call until giving + // up after `MAX_RETRIES` attempts and returning the error. + let mock = Mock::given(matchers::method("POST")) + .and(matchers::path("/customers")) + .respond_with(ResponseTemplate::new(429)) + .expect(u64::from(MAX_RETRIES) + 1) + .named("put customers"); + server.register(mock).await; + let customer_idx = 0; + let res = client + .create_customer(&CreateCustomerRequest { + name: &format!("{TEST_PREFIX}-{customer_idx}"), + email: &format!("orb-testing-{customer_idx}@materialize.com"), + external_id: None, + payment_provider: Some(CustomerPaymentProviderRequest { + kind: PaymentProvider::Stripe, + id: &format!("cus_fake_{customer_idx}"), + }), + ..Default::default() + }) + .await; + + assert!(res.is_err()); +}