Skip to main content

libdd_trace_utils/send_with_retry/
mod.rs

1// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Provide [`send_with_retry`] utility to send a payload to an [`Endpoint`] with retries if the
5//! request fails.
6
7mod 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/// All errors contain the number of attempts after which the final error was returned
26#[derive(Debug)]
27pub enum SendWithRetryError {
28    /// The request received an error HTTP code.
29    Http(http::Response<Bytes>, Attempts),
30    /// Treats timeout errors originated in the transport layer.
31    Timeout(Attempts),
32    /// Treats errors coming from networking.
33    Network(HttpError, Attempts),
34    /// Treats errors while reading the response body.
35    ResponseBody(Attempts),
36    /// Treats errors coming from building the request
37    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/// Send the `payload` with a POST request to `target` using the provided `retry_strategy` if the
57/// request fails.
58///
59/// Standard endpoint headers (user-agent, api-key, test-token, entity headers) are set
60/// automatically via [`Endpoint::set_standard_headers`]. Additional `headers` are appended to the
61/// request. The request is executed with a timeout of [`Endpoint::timeout_ms`].
62///
63/// # Returns
64///
65/// Return a [`SendWithRetryResult`] containing the response and the number of attempts or an error
66/// describing the last attempt failure.
67///
68/// # Errors
69/// Fail if the request didn't succeed after applying the retry strategy.
70///
71/// # Example
72///
73/// ```rust, no_run
74/// # use libdd_common::Endpoint;
75/// # use libdd_capabilities::{HttpClientCapability, SleepCapability};
76/// # use libdd_trace_utils::send_with_retry::*;
77/// # async fn run() -> SendWithRetryResult {
78/// let payload: Vec<u8> = vec![0, 1, 2, 3];
79/// let target = Endpoint {
80///     url: "localhost:8126/v04/traces".parse::<hyper::Uri>().unwrap(),
81///     ..Endpoint::default()
82/// };
83/// let mut headers = http::HeaderMap::new();
84/// headers.insert(
85///     http::HeaderName::from_static("content-type"),
86///     http::HeaderValue::from_static("application/msgpack"),
87/// );
88/// let retry_strategy = RetryStrategy::new(3, 10, RetryBackoffType::Exponential, Some(5));
89/// let capabilities = libdd_capabilities_impl::NativeCapabilities::new_client();
90/// send_with_retry(
91///     &capabilities,
92///     &target,
93///     payload,
94///     &headers,
95///     &retry_strategy,
96///     CompressionStrategy::None,
97/// )
98/// .await
99/// # }
100/// ```
101#[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/// Send a payload with retries and return its post-compression size.
123#[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}