use std::time::Duration;
use progenitor_client::{ClientHooks, ClientInfo, OperationInfo};
use reqwest::{
Method, Request, Response, StatusCode,
header::{CONTENT_LENGTH, HeaderName, HeaderValue, RETRY_AFTER},
};
use crate::api;
pub(crate) const IDEMPOTENCY_KEY: HeaderName = HeaderName::from_static("idempotency-key");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_retries: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub max_retry_after: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 2,
initial_backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(8),
max_retry_after: Duration::from_secs(60),
}
}
}
impl RetryPolicy {
pub fn none() -> Self {
Self {
max_retries: 0,
..Self::default()
}
}
fn backoff(&self, retry: u32) -> Duration {
let exp = self
.initial_backoff
.saturating_mul(2u32.saturating_pow(retry.min(16)));
let capped = exp.min(self.max_backoff);
let half = capped / 2;
half + half.mul_f64(jitter())
}
}
fn jitter() -> f64 {
use std::{
collections::hash_map::RandomState,
hash::{BuildHasher, Hasher},
};
let mut hasher = RandomState::new().build_hasher();
hasher.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default(),
);
(hasher.finish() >> 11) as f64 / (1u64 << 53) as f64
}
#[derive(Debug, Clone, Default)]
pub struct Inner {
pub(crate) retry: RetryPolicy,
pub(crate) idempotency_key: Option<HeaderValue>,
}
const POST_SAFE_OPERATIONS: &[&str] = &[
"generate_image",
"generate_audio",
"generate_video",
"generate3_d",
"generate_3d",
"restore_video",
"remove_background_video",
"quote_job_cost",
"validate_mask",
"validate_motion",
"estimate_preset_intake_run",
"assemble_preset_prompt",
"quote_multicam_run",
];
fn replay_safe(request: &Request, operation: &str) -> bool {
matches!(
*request.method(),
Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
) || request.headers().contains_key(IDEMPOTENCY_KEY)
|| POST_SAFE_OPERATIONS.contains(&operation)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Attempts(pub u32);
pub(crate) async fn execute(
http: &reqwest::Client,
inner: &Inner,
mut request: Request,
operation: &str,
) -> reqwest::Result<Response> {
prepare(inner, &mut request);
let policy = &inner.retry;
let mut retry = 0u32;
loop {
let replay = if retry < policy.max_retries {
request.try_clone()
} else {
None
};
let outcome = http.execute(request).await;
let Some(next) = replay else {
return stamp(outcome, retry);
};
let delay = match &outcome {
Ok(response) => match retry_delay(policy, response, &next, operation, retry) {
Some(delay) => delay,
None => return stamp(outcome, retry),
},
Err(err) if err.is_connect() || (err.is_timeout() && replay_safe(&next, operation)) => {
policy.backoff(retry)
}
Err(err) if err.is_request() && replay_safe(&next, operation) => policy.backoff(retry),
Err(_) => return stamp(outcome, retry),
};
tokio::time::sleep(delay).await;
request = next;
retry += 1;
}
}
fn stamp(outcome: reqwest::Result<Response>, retries: u32) -> reqwest::Result<Response> {
outcome.map(|mut response| {
response.extensions_mut().insert(Attempts(retries));
response
})
}
fn retry_delay(
policy: &RetryPolicy,
response: &Response,
request: &Request,
operation: &str,
retry: u32,
) -> Option<Duration> {
let status = response.status();
let retryable = status == StatusCode::TOO_MANY_REQUESTS
|| (matches!(status.as_u16(), 500 | 502 | 503 | 504) && replay_safe(request, operation));
if !retryable {
return None;
}
match retry_after(response) {
Some(wait) if wait > policy.max_retry_after => None,
Some(wait) => Some(wait),
None => Some(policy.backoff(retry)),
}
}
pub(crate) fn retry_after(response: &Response) -> Option<Duration> {
response
.headers()
.get(RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
.map(Duration::from_secs)
}
fn prepare(inner: &Inner, request: &mut Request) {
let method = request.method().clone();
if method == Method::GET
&& request.timeout().is_none()
&& request.url().path().ends_with("/wait")
&& let Some(window) = request
.url()
.query_pairs()
.find(|(k, _)| k == "timeout_seconds")
.and_then(|(_, v)| v.parse::<u64>().ok())
{
*request.timeout_mut() = Some(Duration::from_secs(window.min(900) + 30));
}
if method == Method::POST
&& let Some(key) = &inner.idempotency_key
&& !request.headers().contains_key(IDEMPOTENCY_KEY)
{
request.headers_mut().insert(IDEMPOTENCY_KEY, key.clone());
}
if matches!(method, Method::POST | Method::PUT | Method::PATCH) && request.body().is_none() {
*request.body_mut() = Some(reqwest::Body::from(Vec::<u8>::new()));
request
.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from_static("0"));
}
}
impl ClientHooks<Inner> for api::Client {
async fn exec(&self, request: Request, info: &OperationInfo) -> reqwest::Result<Response> {
execute(self.client(), self.inner(), request, info.operation_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_and_is_capped() {
let policy = RetryPolicy::default();
for retry in 0..10 {
let delay = policy.backoff(retry);
assert!(delay <= policy.max_backoff, "{delay:?}");
assert!(delay >= policy.initial_backoff.min(policy.max_backoff) / 2);
}
assert!(policy.backoff(5) >= Duration::from_secs(4));
}
#[test]
fn replay_safety_follows_method_key_and_operation() {
let client = reqwest::Client::new();
let get = client.get("http://x/v1/jobs").build().unwrap();
assert!(replay_safe(&get, "list_jobs"));
let post = client.post("http://x/v1/agent/messages").build().unwrap();
assert!(!replay_safe(&post, "submit_agent_message"));
assert!(replay_safe(&post, "generate_image"));
let keyed = client
.post("http://x/v1/agent/messages")
.header(IDEMPOTENCY_KEY, "k")
.build()
.unwrap();
assert!(replay_safe(&keyed, "submit_agent_message"));
}
#[test]
fn bodyless_post_gets_content_length_and_key() {
let inner = Inner {
idempotency_key: Some(HeaderValue::from_static("abc")),
..Inner::default()
};
let mut request = reqwest::Client::new()
.post("http://x/v1/jobs/1/cancel")
.build()
.unwrap();
prepare(&inner, &mut request);
assert_eq!(request.headers()[CONTENT_LENGTH], "0");
assert_eq!(request.headers()[IDEMPOTENCY_KEY], "abc");
let mut get = reqwest::Client::new()
.get("http://x/v1/jobs")
.build()
.unwrap();
prepare(&inner, &mut get);
assert!(!get.headers().contains_key(IDEMPOTENCY_KEY));
assert!(!get.headers().contains_key(CONTENT_LENGTH));
}
}