1mod retry_strategy;
8pub use retry_strategy::{RetryBackoffType, RetryStrategy};
9
10pub(crate) mod compression;
11pub use compression::CompressionStrategy;
12
13use bytes::Bytes;
14use http::HeaderMap;
15use libdd_capabilities::{HttpClientCapability, HttpError, SleepCapability};
16use libdd_common::Endpoint;
17use std::time::Duration;
18use tracing::{debug, error};
19
20pub type Attempts = u32;
21
22pub type SendWithRetryResult = Result<(http::Response<Bytes>, Attempts), SendWithRetryError>;
23
24#[derive(Debug)]
26pub enum SendWithRetryError {
27 Http(http::Response<Bytes>, Attempts),
29 Timeout(Attempts),
31 Network(HttpError, Attempts),
33 ResponseBody(Attempts),
35 Build(Attempts),
37}
38
39impl std::fmt::Display for SendWithRetryError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 SendWithRetryError::Http(_, _) => write!(f, "Http error code received"),
43 SendWithRetryError::Timeout(_) => write!(f, "Request timed out"),
44 SendWithRetryError::Network(error, _) => write!(f, "Network error: {error}"),
45 SendWithRetryError::ResponseBody(_) => write!(f, "Failed to read response body"),
46 SendWithRetryError::Build(_) => {
47 write!(f, "Failed to build request due to invalid property")
48 }
49 }
50 }
51}
52
53impl std::error::Error for SendWithRetryError {}
54
55#[allow(clippy::result_large_err)]
101pub async fn send_with_retry<C: HttpClientCapability + SleepCapability>(
102 capabilities: &C,
103 target: &Endpoint,
104 payload: Vec<u8>,
105 headers: &HeaderMap,
106 retry_strategy: &RetryStrategy,
107 compression_strategy: CompressionStrategy,
108) -> SendWithRetryResult {
109 let mut request_attempt = 0;
110 let timeout = Duration::from_millis(target.timeout_ms);
111
112 debug!(
113 url = %target.url,
114 payload_size = payload.len(),
115 max_retries = retry_strategy.max_retries(),
116 "Sending with retry"
117 );
118
119 let (compressed, compression_strategy) = compression::compress(payload, compression_strategy);
120 let payload = Bytes::from(compressed);
121
122 loop {
123 request_attempt += 1;
124
125 debug!(
126 url = %target.url,
127 attempt = request_attempt,
128 max_retries = retry_strategy.max_retries(),
129 "Attempting request"
130 );
131
132 let mut builder = http::Request::builder()
133 .method(http::Method::POST)
134 .uri(target.url.clone());
135 builder =
136 target.set_standard_headers(builder, concat!("Tracer/", env!("CARGO_PKG_VERSION")));
137 for (key, value) in headers {
138 builder = builder.header(key, value);
139 }
140 if let Some(h) = builder.headers_mut() {
142 compression::add_headers(h, compression_strategy);
143 }
144 let req = match builder.body(payload.clone()) {
145 Ok(r) => r,
146 Err(_) => {
147 return Err(SendWithRetryError::Build(request_attempt));
148 }
149 };
150
151 let result = tokio::select! {
152 biased;
153 r = capabilities.request(req) => Ok(r),
154 _ = capabilities.sleep(timeout) => Err(()),
155 };
156
157 match result {
158 Ok(Ok(response)) => {
159 let status = response.status();
160 debug!(
161 url = %target.url,
162 status = status.as_u16(),
163 attempt = request_attempt,
164 "Received response"
165 );
166
167 if status.is_client_error() || status.is_server_error() {
168 debug!(
169 status = status.as_u16(),
170 attempt = request_attempt,
171 max_retries = retry_strategy.max_retries(),
172 "Received error status code"
173 );
174
175 if request_attempt <= retry_strategy.max_retries() {
176 debug!(
177 attempt = request_attempt,
178 remaining_retries = retry_strategy.max_retries() - request_attempt + 1,
179 "Retrying after error status code"
180 );
181 retry_strategy.delay(request_attempt, capabilities).await;
182 continue;
183 } else {
184 error!(
185 status = status.as_u16(),
186 attempts = request_attempt,
187 "Max retries exceeded, returning HTTP error"
188 );
189 return Err(SendWithRetryError::Http(response, request_attempt));
190 }
191 } else {
192 debug!(
193 status = status.as_u16(),
194 attempts = request_attempt,
195 "Request succeeded"
196 );
197 return Ok((response, request_attempt));
198 }
199 }
200 Ok(Err(e)) => {
201 debug!(
202 url = %target.url,
203 error = ?e,
204 attempt = request_attempt,
205 max_retries = retry_strategy.max_retries(),
206 "Request failed with error"
207 );
208
209 if request_attempt <= retry_strategy.max_retries() {
210 debug!(
211 attempt = request_attempt,
212 remaining_retries = retry_strategy.max_retries() - request_attempt + 1,
213 "Retrying after request error"
214 );
215 retry_strategy.delay(request_attempt, capabilities).await;
216 continue;
217 } else {
218 let classified_error = match e {
219 HttpError::Timeout => SendWithRetryError::Timeout(request_attempt),
220 HttpError::InvalidRequest(_) => SendWithRetryError::Build(request_attempt),
221 HttpError::ResponseBody(_) => {
222 SendWithRetryError::ResponseBody(request_attempt)
223 }
224 other => SendWithRetryError::Network(other, request_attempt),
225 };
226 error!(
227 error = ?classified_error,
228 attempts = request_attempt,
229 "Max retries exceeded, returning request error"
230 );
231 return Err(classified_error);
232 }
233 }
234 Err(_) => {
235 debug!(
236 url = %target.url,
237 attempt = request_attempt,
238 max_retries = retry_strategy.max_retries(),
239 "Request timed out"
240 );
241
242 if request_attempt <= retry_strategy.max_retries() {
243 debug!(
244 attempt = request_attempt,
245 remaining_retries = retry_strategy.max_retries() - request_attempt + 1,
246 "Retrying after timeout"
247 );
248 retry_strategy.delay(request_attempt, capabilities).await;
249 continue;
250 } else {
251 error!(
252 attempts = request_attempt,
253 "Max retries exceeded, returning timeout error"
254 );
255 return Err(SendWithRetryError::Timeout(request_attempt));
256 }
257 }
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::test_utils::poll_for_mock_hit;
266 use httpmock::MockServer;
267 use libdd_capabilities::HttpClientCapability;
268 use libdd_capabilities_impl::NativeCapabilities;
269
270 #[cfg_attr(miri, ignore)]
271 #[tokio::test]
272 async fn test_zero_retries_on_error() {
273 let server = MockServer::start();
274
275 let mut mock_503 = server
276 .mock_async(|_when, then| {
277 then.status(503)
278 .header("content-type", "application/json")
279 .body(r#"{"status":"error"}"#);
280 })
281 .await;
282
283 let _mock_202 = server
284 .mock_async(|_when, then| {
285 then.status(202)
286 .header("content-type", "application/json")
287 .body(r#"{"status":"ok"}"#);
288 })
289 .await;
290
291 let target_endpoint = Endpoint {
292 url: server.url("").to_owned().parse().unwrap(),
293 api_key: Some("test-key".into()),
294 ..Default::default()
295 };
296
297 let strategy = RetryStrategy::new(0, 2, RetryBackoffType::Constant, None);
298 let capabilities = NativeCapabilities::new_client();
299
300 tokio::spawn(async move {
301 let result = send_with_retry(
302 &capabilities,
303 &target_endpoint,
304 vec![0, 1, 2, 3],
305 &HeaderMap::new(),
306 &strategy,
307 CompressionStrategy::None,
308 )
309 .await;
310 assert!(result.is_err(), "Expected an error result");
311 assert!(
312 matches!(result.unwrap_err(), SendWithRetryError::Http(_, 1)),
313 "Expected an http error with one attempt"
314 );
315 });
316
317 assert!(poll_for_mock_hit(&mut mock_503, 10, 100, 1, true).await);
318 }
319
320 #[cfg_attr(miri, ignore)]
321 #[tokio::test]
322 async fn test_retry_logic_error_then_success() {
323 let server = MockServer::start();
324
325 let mut mock_503 = server
326 .mock_async(|_when, then| {
327 then.status(503)
328 .header("content-type", "application/json")
329 .body(r#"{"status":"error"}"#);
330 })
331 .await;
332
333 let mut mock_202 = server
334 .mock_async(|_when, then| {
335 then.status(202)
336 .header("content-type", "application/json")
337 .body(r#"{"status":"ok"}"#);
338 })
339 .await;
340
341 let target_endpoint = Endpoint {
342 url: server.url("").to_owned().parse().unwrap(),
343 api_key: Some("test-key".into()),
344 ..Default::default()
345 };
346
347 let strategy = RetryStrategy::new(2, 250, RetryBackoffType::Constant, None);
348 let capabilities = NativeCapabilities::new_client();
349
350 tokio::spawn(async move {
351 let result = send_with_retry(
352 &capabilities,
353 &target_endpoint,
354 vec![0, 1, 2, 3],
355 &HeaderMap::new(),
356 &strategy,
357 CompressionStrategy::None,
358 )
359 .await;
360 assert!(
361 matches!(result.unwrap(), (_, 2)),
362 "Expected an ok result after two attempts"
363 );
364 });
365
366 assert!(poll_for_mock_hit(&mut mock_503, 10, 100, 1, true).await);
367 assert!(
368 poll_for_mock_hit(&mut mock_202, 10, 100, 1, true).await,
369 "Expected a retry request after a 5xx error"
370 );
371 }
372
373 #[cfg_attr(miri, ignore)]
374 #[tokio::test]
375 async fn test_retry_logic_max_errors() {
376 let server = MockServer::start();
377 let max_retries = 3;
378 let expected_total_attempts = max_retries + 1;
379 let mut mock_503 = server
380 .mock_async(|_when, then| {
381 then.status(503)
382 .header("content-type", "application/json")
383 .body(r#"{"status":"error"}"#);
384 })
385 .await;
386
387 let target_endpoint = Endpoint {
388 url: server.url("").to_owned().parse().unwrap(),
389 api_key: Some("test-key".into()),
390 ..Default::default()
391 };
392
393 let strategy = RetryStrategy::new(max_retries, 10, RetryBackoffType::Constant, None);
394 let capabilities = NativeCapabilities::new_client();
395
396 tokio::spawn(async move {
397 let result = send_with_retry(
398 &capabilities,
399 &target_endpoint,
400 vec![0, 1, 2, 3],
401 &HeaderMap::new(),
402 &strategy,
403 CompressionStrategy::None,
404 )
405 .await;
406 assert!(
407 matches!(result.unwrap_err(), SendWithRetryError::Http(_, attempts) if attempts == expected_total_attempts),
408 "Expected an error result after max retry attempts"
409 );
410 });
411
412 assert!(
413 poll_for_mock_hit(
414 &mut mock_503,
415 10,
416 100,
417 expected_total_attempts as usize,
418 true
419 )
420 .await,
421 "Expected max retry attempts"
422 );
423 }
424
425 #[cfg_attr(miri, ignore)]
426 #[tokio::test]
427 async fn test_retry_logic_no_errors() {
428 let server = MockServer::start();
429 let mut mock_202 = server
430 .mock_async(|_when, then| {
431 then.status(202)
432 .header("content-type", "application/json")
433 .body(r#"{"status":"Ok"}"#);
434 })
435 .await;
436
437 let target_endpoint = Endpoint {
438 url: server.url("").to_owned().parse().unwrap(),
439 api_key: Some("test-key".into()),
440 ..Default::default()
441 };
442
443 let strategy = RetryStrategy::new(2, 10, RetryBackoffType::Constant, None);
444 let capabilities = NativeCapabilities::new_client();
445
446 tokio::spawn(async move {
447 let result = send_with_retry(
448 &capabilities,
449 &target_endpoint,
450 vec![0, 1, 2, 3],
451 &HeaderMap::new(),
452 &strategy,
453 CompressionStrategy::None,
454 )
455 .await;
456 assert!(
457 matches!(result, Ok((_, attempts)) if attempts == 1),
458 "Expected an ok result after one attempts"
459 );
460 });
461
462 assert!(
463 poll_for_mock_hit(&mut mock_202, 10, 250, 1, true).await,
464 "Expected only one request attempt"
465 );
466 }
467}