fhir_sdk/client/
request.rs

1//! HTTP Request implementation.
2
3use std::time::Duration;
4
5use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
6use tokio_retry::{
7	RetryIf,
8	strategy::{ExponentialBackoff, FixedInterval},
9};
10
11use super::{error::Error, misc::make_uuid_header_value};
12
13/// Settings for the HTTP Requests.
14///
15/// By default, 3 retries are done with a fixed retry_time of 1000 ms.
16#[derive(Debug, Clone)]
17pub struct RequestSettings {
18	/// Number of retries to make.
19	retries: usize,
20	/// Duration to wait between the requests (either always for fixed or for
21	/// the first time for exponential backoff).
22	retry_time: Duration,
23	/// Max retry duration between requests. Only relevant for exponential
24	/// backoff.
25	max_retry_time: Option<Duration>,
26	/// Whether to use exponential backoff or not.
27	exp_backoff: bool,
28	/// Timeout for the requests.
29	timeout: Option<Duration>,
30	/// Additional headers to set for the requests.
31	headers: HeaderMap,
32}
33
34impl Default for RequestSettings {
35	fn default() -> Self {
36		Self {
37			retries: 3,
38			retry_time: Duration::from_millis(1000),
39			max_retry_time: None,
40			exp_backoff: false,
41			timeout: None,
42			headers: HeaderMap::new(),
43		}
44	}
45}
46
47impl RequestSettings {
48	/// Set the number of retries.
49	#[must_use]
50	pub const fn retries(mut self, retries: usize) -> Self {
51		self.retries = retries;
52		self
53	}
54
55	/// Set to use fixed interval retrying.
56	#[must_use]
57	pub const fn fixed_retry(mut self, interval: Duration) -> Self {
58		self.exp_backoff = false;
59		self.retry_time = interval;
60		self.max_retry_time = None;
61		self
62	}
63
64	/// Set to use exponential backoff retrying.
65	#[must_use]
66	pub const fn exp_backoff(mut self, start_time: Duration, max_time: Option<Duration>) -> Self {
67		self.exp_backoff = true;
68		self.retry_time = start_time;
69		self.max_retry_time = max_time;
70		self
71	}
72
73	/// Set the request timeout.
74	#[must_use]
75	pub const fn timeout(mut self, timeout: Option<Duration>) -> Self {
76		self.timeout = timeout;
77		self
78	}
79
80	/// Insert a header into the request headers to be set on each request.
81	#[must_use]
82	pub fn header(mut self, header: HeaderName, value: HeaderValue) -> Self {
83		self.headers.insert(header, value);
84		self
85	}
86
87	/// Make a HTTP request using the settings. Returns the response.
88	///
89	/// It is recommended to set the `X-Correlation-Id` header outside, for a whole transaction.
90	#[tracing::instrument(level = "debug", skip_all, fields(x_correlation_id, x_request_id))]
91	pub(crate) async fn make_request(
92		&self,
93		mut request: reqwest::RequestBuilder,
94	) -> Result<reqwest::Response, Error> {
95		if let Some(timeout) = self.timeout {
96			request = request.timeout(timeout);
97		}
98
99		// Add or override default headers with request headers.
100		let (client, request_result) = request.build_split();
101		let mut request = request_result?;
102		let mut headers = self.headers.clone();
103		headers.extend(request.headers().clone());
104		*request.headers_mut() = headers;
105
106		// Add `X-Request-Id` and `X-Correlation-Id` header if not already set.
107		let id_value = make_uuid_header_value();
108		request.headers_mut().entry("X-Correlation-Id").or_insert_with(|| id_value.clone());
109		request.headers_mut().entry("X-Request-Id").or_insert(id_value);
110
111		// Construct the dynamic retry strategy iterator.
112		#[expect(
113			clippy::cast_possible_truncation,
114			reason = "That high backoff is unrealistic and not an issue"
115		)]
116		let strategy: Box<dyn Iterator<Item = Duration> + Send + Sync> = if self.exp_backoff {
117			let mut exp_backoff =
118				ExponentialBackoff::from_millis(self.retry_time.as_millis() as u64);
119			if let Some(max_backoff) = self.max_retry_time {
120				exp_backoff = exp_backoff.max_delay(max_backoff);
121			}
122			Box::new(exp_backoff)
123		} else {
124			Box::new(FixedInterval::from_millis(self.retry_time.as_millis() as u64))
125		};
126
127		// Fill in tracing spans to log the informational/correlational headers.
128		let x_correlation_id =
129			request.headers().get("X-Correlation-Id").and_then(|v| v.to_str().ok());
130		let x_request_id = request.headers().get("X-Request-Id").and_then(|v| v.to_str().ok());
131		tracing::Span::current()
132			.record("x_correlation_id", x_correlation_id)
133			.record("x_request_id", x_request_id);
134
135		// Send the request, but retry on specific failures.
136		RetryIf::spawn(
137			strategy.take(self.retries),
138			async || {
139				tracing::debug!("Sending {} request to {}", request.method(), request.url());
140				let request = request.try_clone().ok_or(Error::RequestNotClone)?;
141				let response = client.execute(request).await?;
142				tracing::debug!("Got response: {}", response.status());
143				Ok(response)
144			},
145			Error::should_retry,
146		)
147		.await
148	}
149}