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