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