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}