Skip to content

Commit a49ff91

Browse files
jubradbenesch
authored andcommitted
use reqwest_retry to automatically retry failed requests
1 parent 78a4ecc commit a49ff91

File tree

8 files changed

+128
-16
lines changed

8 files changed

+128
-16
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616
include:
1717
- build: macos
1818
os: macos-latest
19-
rust: 1.65.0
19+
rust: 1.68.2
2020
- build: ubuntu
2121
os: ubuntu-latest
22-
rust: 1.65.0
22+
rust: 1.68.2
2323
steps:
2424
- uses: actions/checkout@v2
2525
- uses: actions-rs/toolchain@v1
@@ -38,7 +38,7 @@ jobs:
3838
- uses: actions/checkout@v2
3939
- uses: actions-rs/toolchain@v1
4040
with:
41-
toolchain: 1.65.0
41+
toolchain: 1.68.2
4242
default: true
4343
components: rustfmt
4444
- run: cargo fmt -- --check
@@ -50,7 +50,7 @@ jobs:
5050
- uses: actions/checkout@v2
5151
- uses: actions-rs/toolchain@v1
5252
with:
53-
toolchain: 1.65.0
53+
toolchain: 1.68.2
5454
default: true
5555
components: clippy
5656
- uses: actions-rs/clippy-check@v1

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Versioning].
99

1010
## [Unreleased] <!-- #release:date -->
1111

12+
* Automatically retry read-only HTTP requests.
13+
1214
## [0.3.0] - 2023-02-26
1315

1416
* Add the `Client::get_tenant` method to get a tenant by ID.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ categories = ["api-bindings", "web-programming"]
99
keywords = ["frontegg", "front", "egg", "api", "sdk"]
1010
repository = "https://github.com/MaterializeInc/rust-frontegg"
1111
version = "0.3.0"
12-
rust-version = "1.65"
12+
rust-version = "1.68.2"
1313
edition = "2021"
1414

1515
[dependencies]
1616
async-stream = "0.3.3"
1717
futures-core = "0.3.25"
1818
once_cell = "1.16.0"
1919
reqwest = { version = "0.11.13", features = ["json"] }
20+
reqwest-middleware = "0.2.2"
21+
reqwest-retry = "0.2.2"
2022
serde = { version = "1.0.151", features = ["derive"] }
2123
serde_json = "1.0.91"
2224
time = { version = "0.3.17", features = ["serde", "serde-human-readable"] }
@@ -30,6 +32,7 @@ tokio = { version = "1.23.0", features = ["macros"] }
3032
tokio-stream = "0.1.11"
3133
tracing = "0.1.37"
3234
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
35+
wiremock = "0.5.19"
3336

3437
[package.metadata.docs.rs]
3538
all-features = true

src/client.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515

1616
use std::time::{Duration, SystemTime};
1717

18-
use reqwest::{Method, RequestBuilder, Url};
18+
use reqwest::{Method, Url};
19+
use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
1920
use serde::de::DeserializeOwned;
2021
use serde::{Deserialize, Serialize};
2122
use tokio::sync::Mutex;
@@ -38,7 +39,8 @@ const AUTH_VENDOR_PATH: [&str; 2] = ["auth", "vendor"];
3839
/// [`Arc`]: std::sync::Arc
3940
#[derive(Debug)]
4041
pub struct Client {
41-
pub(crate) inner: reqwest::Client,
42+
pub(crate) client_retryable: ClientWithMiddleware,
43+
pub(crate) client_non_retryable: ClientWithMiddleware,
4244
pub(crate) client_id: String,
4345
pub(crate) secret_key: String,
4446
pub(crate) vendor_endpoint: Url,
@@ -67,7 +69,14 @@ impl Client {
6769
.expect("builder validated URL can be a base")
6870
.clear()
6971
.extend(path);
70-
self.inner.request(method, url)
72+
match method {
73+
// GET and HEAD requests are idempotent and we can safely retry
74+
// them without fear of duplicating data.
75+
Method::GET | Method::HEAD => self.client_retryable.request(method, url),
76+
// All other requests are assumed to be mutating and therefore
77+
// we leave it to the caller to retry them.
78+
_ => self.client_non_retryable.request(method, url),
79+
}
7180
}
7281

7382
async fn send_request<T>(&self, req: RequestBuilder) -> Result<T, Error>

src/config.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ use std::time::Duration;
1717

1818
use once_cell::sync::Lazy;
1919
use reqwest::Url;
20+
use reqwest_retry::policies::ExponentialBackoff;
21+
use reqwest_retry::RetryTransientMiddleware;
2022

21-
use crate::Client;
23+
use crate::client::Client;
2224

2325
pub static DEFAULT_VENDOR_ENDPOINT: Lazy<Url> = Lazy::new(|| {
2426
"https://api.frontegg.com"
@@ -37,27 +39,51 @@ pub struct ClientConfig {
3739
/// A builder for a [`Client`].
3840
pub struct ClientBuilder {
3941
vendor_endpoint: Url,
42+
retry_policy: Option<ExponentialBackoff>,
4043
}
4144

4245
impl Default for ClientBuilder {
4346
fn default() -> ClientBuilder {
4447
ClientBuilder {
4548
vendor_endpoint: DEFAULT_VENDOR_ENDPOINT.clone(),
49+
retry_policy: Some(
50+
ExponentialBackoff::builder()
51+
.retry_bounds(Duration::from_millis(100), Duration::from_secs(3))
52+
.build_with_max_retries(5),
53+
),
4654
}
4755
}
4856
}
4957

5058
impl ClientBuilder {
59+
/// Sets the retry policy.
60+
pub fn with_retry_policy(mut self, policy: ExponentialBackoff) -> Self {
61+
self.retry_policy = Some(policy);
62+
self
63+
}
64+
65+
/// Sets the vendor endpoint.
66+
pub fn with_vendor_endpoint(mut self, endpoint: Url) -> Self {
67+
self.vendor_endpoint = endpoint;
68+
self
69+
}
70+
5171
/// Creates a [`Client`] that incorporates the optional parameters
5272
/// configured on the builder and the specified required parameters.
5373
pub fn build(self, config: ClientConfig) -> Client {
54-
let inner = reqwest::ClientBuilder::new()
74+
let client = reqwest::ClientBuilder::new()
5575
.redirect(reqwest::redirect::Policy::none())
5676
.timeout(Duration::from_secs(60))
5777
.build()
5878
.unwrap();
5979
Client {
60-
inner,
80+
client_retryable: match self.retry_policy {
81+
Some(policy) => reqwest_middleware::ClientBuilder::new(client.clone())
82+
.with(RetryTransientMiddleware::new_with_policy(policy))
83+
.build(),
84+
None => reqwest_middleware::ClientBuilder::new(client.clone()).build(),
85+
},
86+
client_non_retryable: reqwest_middleware::ClientBuilder::new(client).build(),
6187
client_id: config.client_id,
6288
secret_key: config.secret_key,
6389
vendor_endpoint: self.vendor_endpoint,

src/error.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use reqwest::StatusCode;
2323
#[derive(Debug)]
2424
pub enum Error {
2525
/// An error in the underlying transport.
26-
Transport(reqwest::Error),
26+
Transport(reqwest_middleware::Error),
2727
/// An error returned by the API.
2828
Api(ApiError),
2929
}
@@ -61,9 +61,15 @@ impl fmt::Display for ApiError {
6161

6262
impl std::error::Error for ApiError {}
6363

64+
impl From<reqwest_middleware::Error> for Error {
65+
fn from(e: reqwest_middleware::Error) -> Error {
66+
Error::Transport(e)
67+
}
68+
}
69+
6470
impl From<reqwest::Error> for Error {
6571
fn from(e: reqwest::Error) -> Error {
66-
Error::Transport(e)
72+
Error::Transport(reqwest_middleware::Error::from(e))
6773
}
6874
}
6975

src/util.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
use std::fmt;
1717
use std::iter;
1818

19-
use reqwest::RequestBuilder;
19+
use reqwest_middleware::RequestBuilder;
2020
use uuid::Uuid;
2121

2222
pub trait RequestBuilderExt {

tests/api.rs

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,17 @@
2626
2727
use std::collections::HashSet;
2828
use std::env;
29+
use std::time::Duration;
2930

3031
use futures::stream::TryStreamExt;
3132
use once_cell::sync::Lazy;
3233
use reqwest::StatusCode;
34+
use reqwest_retry::policies::ExponentialBackoff;
3335
use serde_json::json;
3436
use test_log::test;
3537
use tracing::info;
3638
use uuid::Uuid;
39+
use wiremock::{matchers, Mock, MockServer, ResponseTemplate};
3740

3841
use frontegg::{ApiError, Client, ClientConfig, Error, TenantRequest, UserListConfig, UserRequest};
3942

@@ -60,6 +63,69 @@ async fn delete_existing_tenants(client: &Client) {
6063
}
6164
}
6265

66+
/// Tests that errors are retried automatically by the client for read API calls
67+
/// but not for write API calls.
68+
#[test(tokio::test)]
69+
async fn test_retries_with_mock_server() {
70+
// Start a mock Frontegg API server and a client configured to target that
71+
// server. The retry policy disables backoff to speed up the tests.
72+
const MAX_RETRIES: u32 = 3;
73+
let server = MockServer::start().await;
74+
let client = Client::builder()
75+
.with_vendor_endpoint(server.uri().parse().unwrap())
76+
.with_retry_policy(
77+
ExponentialBackoff::builder()
78+
.retry_bounds(Duration::from_millis(1), Duration::from_millis(1))
79+
.build_with_max_retries(MAX_RETRIES),
80+
)
81+
.build(ClientConfig {
82+
client_id: "".into(),
83+
secret_key: "".into(),
84+
});
85+
86+
// Register authentication handler.
87+
let mock = Mock::given(matchers::path("/auth/vendor"))
88+
.and(matchers::method("POST"))
89+
.respond_with(
90+
ResponseTemplate::new(200)
91+
.set_body_string("{\"token\":\"test\", \"expiresIn\":2687784526}"),
92+
)
93+
.expect(1)
94+
.named("auth");
95+
server.register(mock).await;
96+
97+
// Register a mock for the `get_tenant` call that returns a 429 response
98+
// code and ensure the client repeatedly retries the API call until giving
99+
// up after `MAX_RETRIES` retries and returning the error.
100+
let mock = Mock::given(matchers::method("GET"))
101+
.and(matchers::path_regex("/tenants/.*"))
102+
.respond_with(ResponseTemplate::new(429))
103+
.expect(u64::from(MAX_RETRIES) + 1)
104+
.named("get tenants");
105+
server.register(mock).await;
106+
let res = client.get_tenant(Uuid::new_v4()).await;
107+
assert!(res.is_err());
108+
109+
// Register a mock for the `create_tenant` call that returns a 429 response
110+
// code and ensure the client only tries the API call once.
111+
let mock = Mock::given(matchers::method("POST"))
112+
.and(matchers::path_regex("/tenants/.*"))
113+
.respond_with(ResponseTemplate::new(429))
114+
.expect(1)
115+
.named("post tenants");
116+
server.register(mock).await;
117+
let _ = client
118+
.create_tenant(&TenantRequest {
119+
id: Uuid::new_v4(),
120+
name: &format!("{TENANT_NAME_PREFIX} 1"),
121+
metadata: json!({
122+
"tenant_number": 1,
123+
}),
124+
})
125+
.await;
126+
}
127+
128+
/// Tests basic functionality of creating and retrieving tenants and users.
63129
#[test(tokio::test)]
64130
async fn test_tenants_and_users() {
65131
// Set up.
@@ -146,8 +212,8 @@ async fn test_tenants_and_users() {
146212
// the same properties.
147213
let user = client.get_user(created_user.id).await.unwrap();
148214
assert_eq!(created_user.id, user.id);
149-
assert_eq!(created_user.name, user.name);
150-
assert_eq!(created_user.email, user.email);
215+
assert_eq!(user.name, name);
216+
assert_eq!(user.email, email);
151217
assert_eq!(user.tenants.len(), 1);
152218
assert_eq!(user.tenants[0].tenant_id, tenant.id);
153219

0 commit comments

Comments
 (0)