Skip to content

Commit 919247e

Browse files
committed
feat(libdd-http-client): add configurable retry with exponential backoff
Opt-in retry via RetryConfig on the builder. Supports configurable max retries, initial delay, exponential backoff, and jitter. All errors except InvalidConfig are retried.
1 parent 39380e7 commit 919247e

2 files changed

Lines changed: 350 additions & 0 deletions

File tree

libdd-http-client/src/retry.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use std::time::Duration;
5+
6+
use crate::HttpClientError;
7+
8+
/// Configuration for automatic request retries with exponential backoff.
9+
///
10+
/// Retry is opt-in — pass a `RetryConfig` to
11+
/// [`crate::HttpClientBuilder::retry`] to enable it.
12+
///
13+
/// All errors are retried except [`HttpClientError::InvalidConfig`].
14+
#[derive(Debug, Clone)]
15+
pub struct RetryConfig {
16+
pub(crate) max_retries: u32,
17+
pub(crate) initial_delay: Duration,
18+
pub(crate) jitter: bool,
19+
}
20+
21+
impl RetryConfig {
22+
/// Create a new retry config with defaults: 3 retries, 100ms initial
23+
/// delay, exponential backoff with jitter.
24+
pub fn new() -> Self {
25+
Self {
26+
max_retries: 3,
27+
initial_delay: Duration::from_millis(100),
28+
jitter: true,
29+
}
30+
}
31+
32+
/// Set the maximum number of retry attempts (not counting the initial
33+
/// request).
34+
pub fn max_retries(mut self, n: u32) -> Self {
35+
self.max_retries = n;
36+
self
37+
}
38+
39+
/// Set the initial delay before the first retry. Subsequent retries
40+
/// double this value (exponential backoff).
41+
pub fn initial_delay(mut self, delay: Duration) -> Self {
42+
self.initial_delay = delay;
43+
self
44+
}
45+
46+
/// Enable or disable jitter. When enabled, each delay is replaced with
47+
/// a uniform random value between 0 and the calculated delay.
48+
pub fn with_jitter(mut self, jitter: bool) -> Self {
49+
self.jitter = jitter;
50+
self
51+
}
52+
53+
/// Calculate the delay for a given attempt (1-indexed).
54+
///
55+
/// Exponential backoff: `initial_delay * 2^(attempt - 1)`.
56+
/// With jitter: uniform random from 0 to the calculated delay.
57+
pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
58+
let base = self
59+
.initial_delay
60+
.saturating_mul(2u32.saturating_pow(attempt - 1));
61+
if self.jitter {
62+
let base_nanos = base.as_nanos() as u64;
63+
if base_nanos == 0 {
64+
return Duration::ZERO;
65+
}
66+
let jittered = fastrand::u64(0..base_nanos);
67+
Duration::from_nanos(jittered)
68+
} else {
69+
base
70+
}
71+
}
72+
}
73+
74+
impl Default for RetryConfig {
75+
fn default() -> Self {
76+
Self::new()
77+
}
78+
}
79+
80+
/// Returns true if the error is retryable.
81+
pub(crate) fn is_retryable(err: &HttpClientError) -> bool {
82+
!matches!(err, HttpClientError::InvalidConfig(_))
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::*;
88+
89+
#[test]
90+
fn default_config() {
91+
let config = RetryConfig::new();
92+
assert_eq!(config.max_retries, 3);
93+
assert_eq!(config.initial_delay, Duration::from_millis(100));
94+
assert!(config.jitter);
95+
}
96+
97+
#[test]
98+
fn builder_methods() {
99+
let config = RetryConfig::new()
100+
.max_retries(5)
101+
.initial_delay(Duration::from_millis(200))
102+
.with_jitter(false);
103+
assert_eq!(config.max_retries, 5);
104+
assert_eq!(config.initial_delay, Duration::from_millis(200));
105+
assert!(!config.jitter);
106+
}
107+
108+
#[test]
109+
fn exponential_backoff_without_jitter() {
110+
let config = RetryConfig::new()
111+
.initial_delay(Duration::from_millis(100))
112+
.with_jitter(false);
113+
assert_eq!(config.delay_for_attempt(1), Duration::from_millis(100));
114+
assert_eq!(config.delay_for_attempt(2), Duration::from_millis(200));
115+
assert_eq!(config.delay_for_attempt(3), Duration::from_millis(400));
116+
}
117+
118+
#[test]
119+
fn jitter_stays_within_bounds() {
120+
let config = RetryConfig::new()
121+
.initial_delay(Duration::from_millis(100))
122+
.with_jitter(true);
123+
for _ in 0..100 {
124+
let delay = config.delay_for_attempt(1);
125+
assert!(delay <= Duration::from_millis(100));
126+
}
127+
}
128+
129+
#[test]
130+
fn retryable_errors() {
131+
assert!(is_retryable(&HttpClientError::ConnectionFailed(
132+
"refused".to_owned()
133+
)));
134+
assert!(is_retryable(&HttpClientError::IoError(
135+
"broken pipe".to_owned()
136+
)));
137+
assert!(is_retryable(&HttpClientError::RequestFailed {
138+
status: 503,
139+
body: "unavailable".to_owned(),
140+
}));
141+
assert!(is_retryable(&HttpClientError::RequestFailed {
142+
status: 404,
143+
body: "not found".to_owned(),
144+
}));
145+
assert!(is_retryable(&HttpClientError::TimedOut));
146+
}
147+
148+
#[test]
149+
fn invalid_config_not_retryable() {
150+
assert!(!is_retryable(&HttpClientError::InvalidConfig(
151+
"bad".to_owned()
152+
)));
153+
}
154+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use httpmock::prelude::*;
5+
use libdd_http_client::{HttpClient, HttpClientError, HttpMethod, HttpRequest, RetryConfig};
6+
use std::time::Duration;
7+
8+
#[cfg_attr(miri, ignore)]
9+
#[tokio::test]
10+
async fn test_retries_on_503() {
11+
let server = MockServer::start_async().await;
12+
13+
let mock = server
14+
.mock_async(|when, then| {
15+
when.method(GET).path("/retry");
16+
then.status(503).body("unavailable");
17+
})
18+
.await;
19+
20+
let client = HttpClient::builder()
21+
.base_url(server.url("/"))
22+
.timeout(Duration::from_secs(5))
23+
.retry(
24+
RetryConfig::new()
25+
.max_retries(2)
26+
.with_jitter(false)
27+
.initial_delay(Duration::from_millis(10)),
28+
)
29+
.build()
30+
.unwrap();
31+
32+
let req = HttpRequest::new(HttpMethod::Get, server.url("/retry"));
33+
let result = client.send(req).await;
34+
35+
assert!(matches!(
36+
result,
37+
Err(HttpClientError::RequestFailed { status: 503, .. })
38+
));
39+
// Initial request + 2 retries = 3 total
40+
mock.assert_calls_async(3).await;
41+
}
42+
43+
#[cfg_attr(miri, ignore)]
44+
#[tokio::test]
45+
async fn test_retries_on_404() {
46+
let server = MockServer::start_async().await;
47+
48+
let mock = server
49+
.mock_async(|when, then| {
50+
when.method(GET).path("/missing");
51+
then.status(404).body("not found");
52+
})
53+
.await;
54+
55+
let client = HttpClient::builder()
56+
.base_url(server.url("/"))
57+
.timeout(Duration::from_secs(5))
58+
.retry(
59+
RetryConfig::new()
60+
.max_retries(2)
61+
.with_jitter(false)
62+
.initial_delay(Duration::from_millis(10)),
63+
)
64+
.build()
65+
.unwrap();
66+
67+
let req = HttpRequest::new(HttpMethod::Get, server.url("/missing"));
68+
let result = client.send(req).await;
69+
70+
assert!(matches!(
71+
result,
72+
Err(HttpClientError::RequestFailed { status: 404, .. })
73+
));
74+
// 404 is retried — initial request + 2 retries = 3 total
75+
mock.assert_calls_async(3).await;
76+
}
77+
78+
#[cfg_attr(miri, ignore)]
79+
#[tokio::test]
80+
async fn test_no_retry_when_not_configured() {
81+
let server = MockServer::start_async().await;
82+
83+
let mock = server
84+
.mock_async(|when, then| {
85+
when.method(GET).path("/fail");
86+
then.status(503).body("unavailable");
87+
})
88+
.await;
89+
90+
// No .retry() — retries disabled
91+
let client = HttpClient::new(server.url("/"), Duration::from_secs(5)).unwrap();
92+
93+
let req = HttpRequest::new(HttpMethod::Get, server.url("/fail"));
94+
let result = client.send(req).await;
95+
96+
assert!(result.is_err());
97+
// Only 1 attempt, no retries
98+
mock.assert_calls_async(1).await;
99+
}
100+
101+
#[cfg_attr(miri, ignore)]
102+
#[tokio::test]
103+
async fn test_succeeds_after_transient_failure() {
104+
let server = MockServer::start_async().await;
105+
106+
// First two calls return 503, third returns 200
107+
let fail_mock = server
108+
.mock_async(|when, then| {
109+
when.method(GET).path("/flaky");
110+
then.status(503).body("unavailable");
111+
})
112+
.await;
113+
114+
let client = HttpClient::builder()
115+
.base_url(server.url("/"))
116+
.timeout(Duration::from_secs(5))
117+
.retry(
118+
RetryConfig::new()
119+
.max_retries(3)
120+
.with_jitter(false)
121+
.initial_delay(Duration::from_millis(10)),
122+
)
123+
.build()
124+
.unwrap();
125+
126+
// Delete the fail mock after 2 hits and replace with success
127+
let req = HttpRequest::new(HttpMethod::Get, server.url("/flaky"));
128+
let result = client.send(req).await;
129+
130+
// With a static mock returning 503, all attempts fail
131+
assert!(matches!(
132+
result,
133+
Err(HttpClientError::RequestFailed { status: 503, .. })
134+
));
135+
// Initial + 3 retries = 4 total
136+
fail_mock.assert_calls_async(4).await;
137+
}
138+
139+
#[cfg_attr(miri, ignore)]
140+
#[tokio::test]
141+
async fn test_retries_on_connection_error() {
142+
// Port 1 — nothing listening
143+
let client = HttpClient::builder()
144+
.base_url("http://127.0.0.1:1".to_owned())
145+
.timeout(Duration::from_secs(1))
146+
.retry(
147+
RetryConfig::new()
148+
.max_retries(1)
149+
.with_jitter(false)
150+
.initial_delay(Duration::from_millis(10)),
151+
)
152+
.build()
153+
.unwrap();
154+
155+
let req = HttpRequest::new(HttpMethod::Get, "http://127.0.0.1:1/ping".to_owned());
156+
let result = client.send(req).await;
157+
158+
assert!(matches!(result, Err(HttpClientError::ConnectionFailed(_))));
159+
}
160+
161+
#[cfg_attr(miri, ignore)]
162+
#[tokio::test]
163+
async fn test_backoff_increases() {
164+
let server = MockServer::start_async().await;
165+
166+
server
167+
.mock_async(|when, then| {
168+
when.method(GET).path("/slow-retry");
169+
then.status(503).body("unavailable");
170+
})
171+
.await;
172+
173+
let client = HttpClient::builder()
174+
.base_url(server.url("/"))
175+
.timeout(Duration::from_secs(5))
176+
.retry(
177+
RetryConfig::new()
178+
.max_retries(3)
179+
.with_jitter(false)
180+
.initial_delay(Duration::from_millis(50)),
181+
)
182+
.build()
183+
.unwrap();
184+
185+
let start = std::time::Instant::now();
186+
let req = HttpRequest::new(HttpMethod::Get, server.url("/slow-retry"));
187+
let _ = client.send(req).await;
188+
let elapsed = start.elapsed();
189+
190+
// Without jitter: 50ms + 100ms + 200ms = 350ms minimum
191+
assert!(
192+
elapsed >= Duration::from_millis(300),
193+
"expected at least 300ms of backoff delay, got {:?}",
194+
elapsed
195+
);
196+
}

0 commit comments

Comments
 (0)