1use std::time::Duration;
17
18use reqwest::header::HeaderMap;
19use reqwest::{Method, Request, Response, StatusCode};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RetryPolicy {
27 pub max_retries: u32,
30 pub initial_backoff: Duration,
32 pub max_backoff: Duration,
34 pub unhealthy_backoff: Duration,
39}
40
41impl Default for RetryPolicy {
42 fn default() -> Self {
43 Self {
44 max_retries: 3,
45 initial_backoff: Duration::from_secs(2),
46 max_backoff: Duration::from_secs(60),
47 unhealthy_backoff: Duration::from_secs(90),
48 }
49 }
50}
51
52impl RetryPolicy {
53 pub fn none() -> Self {
55 Self {
56 max_retries: 0,
57 ..Self::default()
58 }
59 }
60
61 fn backoff(&self, retry_index: u32) -> Duration {
63 self.initial_backoff
64 .saturating_mul(2u32.saturating_pow(retry_index))
65 .min(self.max_backoff)
66 }
67}
68
69const RETRYABLE_STATUSES: [StatusCode; 5] = [
72 StatusCode::REQUEST_TIMEOUT,
73 StatusCode::TOO_MANY_REQUESTS,
74 StatusCode::BAD_GATEWAY,
75 StatusCode::SERVICE_UNAVAILABLE,
76 StatusCode::GATEWAY_TIMEOUT,
77];
78
79const IDEMPOTENT_OPERATIONS: &[&str] = &[
84 "post_pipeline_start",
85 "post_pipeline_pause",
86 "post_pipeline_resume",
87 "post_pipeline_stop",
88 "post_pipeline_clear",
89 "post_pipeline_activate",
90 "post_pipeline_approve",
91 "post_pipeline_dismiss_error",
92 "post_pipeline_input_connector_action",
93 "post_pipeline_diff",
94 "post_validate_program",
95 "patch_pipeline",
97 "patch_tenant",
98];
99
100fn is_idempotent(method: &Method, operation_id: &str) -> bool {
103 matches!(
104 *method,
105 Method::GET | Method::HEAD | Method::PUT | Method::DELETE
106 ) || IDEMPOTENT_OPERATIONS.contains(&operation_id)
107}
108
109fn is_never_dispatched_503(body: &[u8]) -> bool {
120 serde_json::from_slice::<serde_json::Value>(body).is_ok_and(|v| {
121 v.get("error_code").and_then(|c| c.as_str()) == Some("PipelineInteractionUnreachable")
122 && v.get("message")
123 .and_then(|m| m.as_str())
124 .is_some_and(|m| m.contains("Failed to connect to host"))
125 })
126}
127
128fn rebuild_response(status: StatusCode, headers: HeaderMap, body: bytes::Bytes) -> Response {
132 let mut rebuilt = http::Response::new(body);
133 *rebuilt.status_mut() = status;
134 *rebuilt.headers_mut() = headers;
135 Response::from(rebuilt)
136}
137
138fn retry_after(headers: &HeaderMap) -> Option<Duration> {
141 let secs = headers
142 .get(reqwest::header::RETRY_AFTER)?
143 .to_str()
144 .ok()?
145 .trim()
146 .parse()
147 .ok()?;
148 Some(Duration::from_secs(secs))
149}
150
151const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
154
155async fn cluster_is_healthy(client: &reqwest::Client, baseurl: &str) -> bool {
160 let url = format!("{}/v0/cluster_healthz", baseurl.trim_end_matches('/'));
161 match client.get(url).timeout(HEALTH_PROBE_TIMEOUT).send().await {
162 Ok(response) => response.status().is_success(),
163 Err(_) => false,
164 }
165}
166
167fn next_wait(
173 policy: &RetryPolicy,
174 retry_index: u32,
175 server_wait: Option<Duration>,
176 cluster_healthy_after_502: Option<bool>,
177) -> Duration {
178 match (server_wait, cluster_healthy_after_502) {
179 (Some(server_wait), _) => server_wait.min(policy.max_backoff),
180 (None, Some(true)) => Duration::ZERO,
181 (None, Some(false)) => policy.unhealthy_backoff,
182 (None, None) => policy.backoff(retry_index),
183 }
184}
185
186enum Verdict {
187 Return(Response),
188 Retry {
189 reason: &'static str,
190 server_wait: Option<Duration>,
193 status: StatusCode,
194 },
195}
196
197async fn judge_response(response: Response, idempotent: bool) -> reqwest::Result<Verdict> {
200 let status = response.status();
201 if !RETRYABLE_STATUSES.contains(&status) {
202 return Ok(Verdict::Return(response));
203 }
204 let server_wait = retry_after(response.headers());
205 if idempotent {
206 return Ok(Verdict::Retry {
207 reason: "transient HTTP status",
208 server_wait,
209 status,
210 });
211 }
212 if status != StatusCode::SERVICE_UNAVAILABLE {
213 return Ok(Verdict::Return(response));
214 }
215 let headers = response.headers().clone();
216 let body = response.bytes().await?;
217 if is_never_dispatched_503(&body) {
218 Ok(Verdict::Retry {
219 reason: "pipeline unreachable, request never sent",
220 server_wait,
221 status,
222 })
223 } else {
224 Ok(Verdict::Return(rebuild_response(status, headers, body)))
225 }
226}
227
228pub(crate) async fn execute_with_retry(
232 client: &reqwest::Client,
233 policy: &RetryPolicy,
234 baseurl: &str,
235 mut request: Request,
236 operation_id: &str,
237) -> reqwest::Result<Response> {
238 let idempotent = is_idempotent(request.method(), operation_id);
239 let mut retry_index = 0u32;
240 loop {
241 let retry_request = if retry_index < policy.max_retries {
244 request.try_clone()
245 } else {
246 None
247 };
248
249 let retries_remain = retry_index < policy.max_retries;
250 let outcome = client.execute(request).await;
251
252 let Some(retry_request) = retry_request else {
253 let failed_transiently = match &outcome {
256 Ok(response) => RETRYABLE_STATUSES.contains(&response.status()),
257 Err(_) => true,
258 };
259 if retries_remain && failed_transiently {
260 log::info!(
261 "{operation_id}: not retrying a transient failure because the request body is a stream and cannot be resent"
262 );
263 }
264 return outcome;
265 };
266
267 let (reason, detail, server_wait, retried_status) = match outcome {
268 Ok(response) => match judge_response(response, idempotent).await? {
269 Verdict::Return(response) => return Ok(response),
270 Verdict::Retry {
271 reason,
272 server_wait,
273 status,
274 } => (reason, String::new(), server_wait, Some(status)),
275 },
276 Err(error) => {
277 if !(idempotent || error.is_connect()) {
282 return Err(error);
283 }
284 ("transport error", format!(": {error}"), None, None)
285 }
286 };
287
288 request = retry_request;
289 let cluster_healthy_after_502 =
293 if server_wait.is_none() && retried_status == Some(StatusCode::BAD_GATEWAY) {
294 Some(cluster_is_healthy(client, baseurl).await)
295 } else {
296 None
297 };
298 let wait = next_wait(policy, retry_index, server_wait, cluster_healthy_after_502);
299 retry_index += 1;
300 log::debug!(
301 "{operation_id}: {reason}{detail} - retrying in {}s (attempt {} of {})",
302 wait.as_secs(),
303 retry_index + 1,
304 policy.max_retries + 1,
305 );
306 tokio::time::sleep(wait).await;
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
316 fn backoff_doubles_and_caps() {
317 let policy = RetryPolicy {
318 max_retries: 5,
319 initial_backoff: Duration::from_secs(2),
320 max_backoff: Duration::from_secs(6),
321 ..RetryPolicy::default()
322 };
323 assert_eq!(policy.backoff(0), Duration::from_secs(2));
324 assert_eq!(policy.backoff(1), Duration::from_secs(4));
325 assert_eq!(policy.backoff(2), Duration::from_secs(6));
326 assert_eq!(policy.backoff(30), Duration::from_secs(6));
327 }
328
329 #[test]
331 fn idempotency_classification() {
332 assert!(is_idempotent(&Method::GET, "get_pipeline"));
333 assert!(is_idempotent(&Method::PUT, "put_pipeline"));
334 assert!(is_idempotent(&Method::DELETE, "delete_pipeline"));
335 assert!(is_idempotent(&Method::POST, "post_pipeline_start"));
336 assert!(!is_idempotent(&Method::POST, "clock_advance"));
337 assert!(!is_idempotent(&Method::POST, "http_input"));
338 assert!(!is_idempotent(&Method::PATCH, "patch_something_new"));
339 }
340
341 #[test]
344 fn next_wait_selection() {
345 let policy = RetryPolicy {
346 max_retries: 3,
347 initial_backoff: Duration::from_secs(2),
348 max_backoff: Duration::from_secs(60),
349 unhealthy_backoff: Duration::from_secs(90),
350 };
351 assert_eq!(
353 next_wait(&policy, 0, Some(Duration::from_secs(7)), None),
354 Duration::from_secs(7)
355 );
356 assert_eq!(
357 next_wait(&policy, 0, Some(Duration::from_secs(600)), None),
358 Duration::from_secs(60)
359 );
360 assert_eq!(next_wait(&policy, 0, None, Some(true)), Duration::ZERO);
362 assert_eq!(
363 next_wait(&policy, 0, None, Some(false)),
364 Duration::from_secs(90)
365 );
366 assert_eq!(next_wait(&policy, 1, None, None), Duration::from_secs(4));
368 }
369
370 #[test]
372 fn retry_after_parses_seconds_only() {
373 let with = |v: &str| {
374 let mut h = HeaderMap::new();
375 h.insert(reqwest::header::RETRY_AFTER, v.parse().unwrap());
376 h
377 };
378 assert_eq!(retry_after(&with("7")), Some(Duration::from_secs(7)));
379 assert_eq!(retry_after(&with("Wed, 21 Oct 2026 07:28:00 GMT")), None);
380 assert_eq!(retry_after(&with("-3")), None);
381 assert_eq!(retry_after(&HeaderMap::new()), None);
382 }
383
384 #[test]
386 fn never_dispatched_detection() {
387 let connect = br#"{"message": "Failed to connect to host: Timeout while establishing connection", "error_code": "PipelineInteractionUnreachable", "details": {}}"#;
388 let timeout = br#"{"message": "timeout (5s) was reached", "error_code": "PipelineInteractionUnreachable", "details": {}}"#;
389 let other_code = br#"{"message": "Failed to connect to host", "error_code": "PipelineInteractionNotDeployed", "details": {}}"#;
390 assert!(is_never_dispatched_503(connect));
391 assert!(!is_never_dispatched_503(timeout));
392 assert!(!is_never_dispatched_503(other_code));
393 assert!(!is_never_dispatched_503(b"not json"));
394 }
395}