Skip to main content

locode_provider/
http.rs

1//! The shared HTTP transport layer (hoisted from `anthropic/` at Task 18):
2//! retry policy + backoff, the [`HttpFailure`] carrier, `Retry-After` parsing,
3//! client construction, and schema normalization. Per-wire error *classification*
4//! stays wire-local (body shapes and terminal heuristics genuinely differ).
5
6use std::future::Future;
7use std::time::Duration;
8
9use crate::provider::ProviderError;
10
11/// A classified HTTP failure plus the retry-control signals that ride beside
12/// the error taxonomy (`x-should-retry` override — Anthropic-only, other wires
13/// always pass `false` — and `Retry-After`).
14#[derive(Debug)]
15pub struct HttpFailure {
16    /// The classified error.
17    pub error: ProviderError,
18    /// A wire-specific "do not retry" override (Anthropic's
19    /// `x-should-retry: false`); forces terminal regardless of the taxonomy.
20    pub force_terminal: bool,
21    /// Parsed `Retry-After` delay (integer seconds only; HTTP-dates → `None`).
22    pub retry_after: Option<Duration>,
23}
24
25impl HttpFailure {
26    /// A transport-layer failure (no HTTP status).
27    pub fn transport(message: impl Into<String>) -> Self {
28        Self {
29            error: ProviderError::Transport(message.into()),
30            force_terminal: false,
31            retry_after: None,
32        }
33    }
34
35    /// A terminal decode failure.
36    pub fn decode(message: impl Into<String>) -> Self {
37        Self {
38            error: ProviderError::Decode(message.into()),
39            force_terminal: false,
40            retry_after: None,
41        }
42    }
43}
44
45/// Parse a `Retry-After` header value: integer delta-seconds only. HTTP-date
46/// forms → `None` (fall back to exp backoff) — grok's simplification.
47#[must_use]
48pub fn parse_retry_after(value: &str) -> Option<Duration> {
49    value.trim().parse::<u64>().ok().map(Duration::from_secs)
50}
51
52/// Transport-retry knobs (Task-12 plan §3.6; sources: Claude Code 500ms·2ⁿ cap
53/// 32s, grok's 429-cap-2 and 120s `Retry-After` cap, codex's ±10% jitter).
54#[derive(Debug, Clone)]
55pub struct RetryPolicy {
56    /// Total attempts for retryable failures.
57    pub max_attempts: u32,
58    /// First backoff delay; doubles each attempt.
59    pub base_delay: Duration,
60    /// Exponential-backoff ceiling (`Retry-After` bypasses it).
61    pub max_delay: Duration,
62    /// 429-specific attempt cap — surface the rate limit, never hammer.
63    pub rate_limit_attempts: u32,
64    /// Ceiling on a server-sent `Retry-After`.
65    pub retry_after_cap: Duration,
66}
67
68impl Default for RetryPolicy {
69    fn default() -> Self {
70        Self {
71            max_attempts: 8,
72            base_delay: Duration::from_millis(500),
73            max_delay: Duration::from_secs(32),
74            rate_limit_attempts: 2,
75            retry_after_cap: Duration::from_mins(2),
76        }
77    }
78}
79
80/// Compute the pre-retry sleep for `attempt` (1-based). `Retry-After` takes
81/// precedence and bypasses the exponential cap (itself capped); otherwise
82/// exponential with ±10% jitter.
83#[must_use]
84pub fn backoff(policy: &RetryPolicy, attempt: u32, retry_after: Option<Duration>) -> Duration {
85    if let Some(after) = retry_after {
86        return after.min(policy.retry_after_cap);
87    }
88    let exp = policy
89        .base_delay
90        .saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)))
91        .min(policy.max_delay);
92    let jitter: f64 = rand::Rng::random_range(&mut rand::rng(), 0.9..1.1);
93    exp.mul_f64(jitter)
94}
95
96/// Drive `op` until success, a terminal classification, or attempt exhaustion.
97/// Generic over the success type — the loop only inspects [`HttpFailure`].
98///
99/// # Errors
100/// The last failure's [`ProviderError`] when attempts are exhausted or the
101/// failure is terminal.
102pub async fn run_with_retry<T, F, Fut>(policy: &RetryPolicy, mut op: F) -> Result<T, ProviderError>
103where
104    F: FnMut(u32) -> Fut,
105    Fut: Future<Output = Result<T, HttpFailure>>,
106{
107    let mut rate_limit_hits: u32 = 0;
108    let mut attempt: u32 = 0;
109    loop {
110        attempt += 1;
111        let failure = match op(attempt).await {
112            Ok(value) => return Ok(value),
113            Err(failure) => failure,
114        };
115
116        if failure.force_terminal || !failure.error.retryable() {
117            return Err(failure.error);
118        }
119        if matches!(failure.error, ProviderError::RateLimited { .. }) {
120            rate_limit_hits += 1;
121            if rate_limit_hits > policy.rate_limit_attempts {
122                return Err(failure.error);
123            }
124        }
125        if attempt >= policy.max_attempts {
126            return Err(failure.error);
127        }
128        tokio::time::sleep(backoff(policy, attempt, failure.retry_after)).await;
129    }
130}
131
132/// Build the reqwest client with the wires' shared timeouts (30s connect,
133/// 10min total — non-streaming reasoning turns can run minutes).
134///
135/// # Errors
136/// [`ProviderError::Transport`] when the client cannot be constructed.
137pub fn build_http_client() -> Result<reqwest::Client, ProviderError> {
138    reqwest::Client::builder()
139        .connect_timeout(Duration::from_secs(30))
140        .timeout(Duration::from_mins(10))
141        .build()
142        .map_err(|e| ProviderError::Transport(format!("client construction: {e}")))
143}
144
145/// Normalize a schemars-derived schema into the `input_schema`/`parameters`
146/// the APIs accept: our flat `Args` structs emit no `$defs`/`$ref` (generation
147/// inlines subschemas at the source), so normalization reduces to stripping
148/// the top-level `$schema` meta-annotation. Shared by every wire.
149#[must_use]
150pub fn normalize_input_schema(mut schema: serde_json::Value) -> serde_json::Value {
151    if let Some(obj) = schema.as_object_mut() {
152        obj.remove("$schema");
153    }
154    schema
155}