Skip to main content

proof_engine/networking/
http.rs

1//! HTTP client with retry, caching, and rate limiting.
2//!
3//! All requests are queued and driven by `tick()`. Results arrive as
4//! `HttpEvent` values polled from `drain_events()`. Each request runs on a
5//! short-lived background thread using [`ureq`](https://crates.io/crates/ureq)
6//! (rustls for HTTPS, no OpenSSL), so `tick()` never blocks the frame.
7//!
8//! The network backend needs the `http` cargo feature. Without it every
9//! request fails at once with [`HttpError::InvalidRequest`] saying so; before
10//! 0.3.0 every request was instead answered with a made-up empty `200 OK`.
11//!
12//! ## Features
13//! - GET, POST, PUT, DELETE, PATCH
14//! - Per-request timeout
15//! - Retry with exponential backoff and jitter
16//! - In-memory response cache (ETag / Last-Modified)
17//! - Rate limiter (token bucket per base URL)
18//! - JSON body helpers
19//! - Binary response support
20
21#![warn(missing_docs)]
22
23use std::collections::{HashMap, VecDeque};
24use std::time::{Duration, Instant};
25
26// ── Method ────────────────────────────────────────────────────────────────────
27
28/// HTTP request method.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Method {
31    /// GET.
32    Get,
33    /// POST.
34    Post,
35    /// PUT.
36    Put,
37    /// DELETE.
38    Delete,
39    /// PATCH.
40    Patch,
41    /// HEAD.
42    Head,
43    /// OPTIONS.
44    Options,
45}
46
47impl Method {
48    /// The method name as sent on the wire, for example `"GET"`.
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::Get     => "GET",
52            Self::Post    => "POST",
53            Self::Put     => "PUT",
54            Self::Delete  => "DELETE",
55            Self::Patch   => "PATCH",
56            Self::Head    => "HEAD",
57            Self::Options => "OPTIONS",
58        }
59    }
60}
61
62// ── HttpRequest ───────────────────────────────────────────────────────────────
63
64/// An HTTP request to be issued by the client.
65#[derive(Debug, Clone)]
66pub struct HttpRequest {
67    /// Unique id, used to match events to requests.
68    pub id:           RequestId,
69    /// HTTP method.
70    pub method:       Method,
71    /// Full URL including scheme, for example `https://example.com/scores`.
72    pub url:          String,
73    /// Request headers.
74    pub headers:      HashMap<String, String>,
75    /// Request body, if any.
76    pub body:         Option<Vec<u8>>,
77    /// Time allowed for each attempt (default 10 s).
78    pub timeout:      Duration,
79    /// Retries after a connection error, timeout or 5xx response (default 3).
80    pub max_retries:  u32,
81    /// Priority: higher = processed first. Default 0.
82    pub priority:     i32,
83    /// Tag for grouping/cancellation.
84    pub tag:          Option<String>,
85    /// Cache behavior.
86    pub cache_policy: CachePolicy,
87}
88
89/// How a GET request uses the response cache.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub enum CachePolicy {
92    /// Never cache.
93    NoStore,
94    /// Use cached response if fresh.
95    UseCache,
96    /// Revalidate with ETag even if cached.
97    Revalidate,
98    /// Force fresh fetch, bypass cache.
99    NoCache,
100}
101
102impl HttpRequest {
103    /// A GET request.
104    pub fn get(url: impl Into<String>) -> Self {
105        Self::new(Method::Get, url)
106    }
107
108    /// A POST request with a raw body.
109    pub fn post(url: impl Into<String>, body: Vec<u8>) -> Self {
110        let mut r = Self::new(Method::Post, url);
111        r.body = Some(body);
112        r
113    }
114
115    /// A POST request with a JSON body and `Content-Type: application/json`.
116    pub fn post_json(url: impl Into<String>, json: impl Into<String>) -> Self {
117        let mut r = Self::new(Method::Post, url);
118        r.body = Some(json.into().into_bytes());
119        r.headers.insert("Content-Type".into(), "application/json".into());
120        r
121    }
122
123    /// A request with a fresh id, 10 s timeout, 3 retries and `UseCache`.
124    pub fn new(method: Method, url: impl Into<String>) -> Self {
125        Self {
126            id:           RequestId::next(),
127            method,
128            url:          url.into(),
129            headers:      HashMap::new(),
130            body:         None,
131            timeout:      Duration::from_secs(10),
132            max_retries:  3,
133            priority:     0,
134            tag:          None,
135            cache_policy: CachePolicy::UseCache,
136        }
137    }
138
139    /// Add or replace a header.
140    pub fn with_header(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
141        self.headers.insert(key.into(), val.into());
142        self
143    }
144
145    /// Set the per-attempt timeout.
146    pub fn with_timeout(mut self, t: Duration) -> Self { self.timeout = t; self }
147    /// Set the number of retries.
148    pub fn with_retries(mut self, n: u32) -> Self { self.max_retries = n; self }
149    /// Set the priority; higher is sent first.
150    pub fn with_priority(mut self, p: i32) -> Self { self.priority = p; self }
151    /// Set a tag that `HttpClient::cancel_by_tag` can match.
152    pub fn with_tag(mut self, t: impl Into<String>) -> Self { self.tag = Some(t.into()); self }
153    /// Set the cache policy.
154    pub fn with_cache(mut self, p: CachePolicy) -> Self { self.cache_policy = p; self }
155
156    /// Add `Authorization: Bearer <token>`.
157    pub fn bearer_auth(self, token: impl Into<String>) -> Self {
158        self.with_header("Authorization", format!("Bearer {}", token.into()))
159    }
160}
161
162// ── RequestId ─────────────────────────────────────────────────────────────────
163
164/// Identifies one request across its retries and events.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub struct RequestId(pub u64);
167
168impl RequestId {
169    /// A new process-wide unique id.
170    pub fn next() -> Self {
171        use std::sync::atomic::{AtomicU64, Ordering};
172        static COUNTER: AtomicU64 = AtomicU64::new(1);
173        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
174    }
175}
176
177// ── HttpResponse ──────────────────────────────────────────────────────────────
178
179/// A response from the server (or from the cache).
180#[derive(Debug, Clone)]
181pub struct HttpResponse {
182    /// HTTP status code.
183    pub status:   u16,
184    /// Response headers, names in lower case.
185    pub headers:  HashMap<String, String>,
186    /// Raw response body.
187    pub body:     Vec<u8>,
188    /// Parsed as UTF-8 if possible.
189    pub text:     Option<String>,
190    /// Time from sending the request to reading the whole body.
191    pub latency:  Duration,
192    /// True if this response was served from the client's cache.
193    pub from_cache: bool,
194}
195
196impl HttpResponse {
197    /// Status is 2xx.
198    pub fn is_success(&self) -> bool { (200..300).contains(&self.status) }
199    /// Status is 4xx.
200    pub fn is_client_error(&self) -> bool { (400..500).contains(&self.status) }
201    /// Status is 5xx.
202    pub fn is_server_error(&self) -> bool { (500..600).contains(&self.status) }
203    /// Status is 304 Not Modified.
204    pub fn is_not_modified(&self) -> bool { self.status == 304 }
205
206    /// The `content-type` header.
207    pub fn content_type(&self) -> Option<&str> {
208        self.headers.get("content-type").map(|s| s.as_str())
209    }
210
211    /// The `etag` header.
212    pub fn etag(&self) -> Option<&str> {
213        self.headers.get("etag").map(|s| s.as_str())
214    }
215
216    /// The `last-modified` header.
217    pub fn last_modified(&self) -> Option<&str> {
218        self.headers.get("last-modified").map(|s| s.as_str())
219    }
220
221    /// The body as UTF-8 text, or `""` if it is not valid UTF-8.
222    pub fn text_body(&self) -> &str {
223        self.text.as_deref().unwrap_or("")
224    }
225
226    /// Parse the body as JSON.
227    pub fn json(&self) -> Result<serde_json::Value, serde_json::Error> {
228        serde_json::from_slice(&self.body)
229    }
230
231    /// A top-level field of a JSON object body, as text: strings are
232    /// returned unquoted and unescaped, other values as JSON (`42`, `true`).
233    /// `None` if the body is not a JSON object, has no such key, or the value
234    /// is `null`.
235    pub fn json_field(&self, key: &str) -> Option<String> {
236        match self.json().ok()?.get(key)? {
237            serde_json::Value::String(s) => Some(s.clone()),
238            serde_json::Value::Null => None,
239            other => Some(other.to_string()),
240        }
241    }
242}
243
244// ── HttpEvent ─────────────────────────────────────────────────────────────────
245
246/// Something that happened to a request, from `HttpClient::drain_events`.
247#[derive(Debug, Clone)]
248pub enum HttpEvent {
249    /// The server answered (any status; check `response.is_success()`).
250    Success {
251        /// The request.
252        id: RequestId,
253        /// The server's answer.
254        response: HttpResponse,
255    },
256    /// A request failed after all retries.
257    Failure {
258        /// The request.
259        id: RequestId,
260        /// What went wrong.
261        error: HttpError,
262        /// The request URL.
263        url: String,
264    },
265    /// A request timed out on its last attempt.
266    Timeout {
267        /// The request.
268        id: RequestId,
269        /// The request URL.
270        url: String,
271    },
272    /// A request was cancelled.
273    Cancelled {
274        /// The request.
275        id: RequestId,
276    },
277    /// Rate limit hit: request was delayed.
278    RateLimited {
279        /// The request.
280        id: RequestId,
281        /// How long it waits before it can be sent, in milliseconds.
282        delay_ms: u64,
283    },
284}
285
286/// Why a request failed.
287#[derive(Debug, Clone)]
288pub enum HttpError {
289    /// Could not establish connection.
290    ConnectionFailed(String),
291    /// DNS resolution failed.
292    DnsFailure(String),
293    /// TLS/SSL error.
294    TlsError(String),
295    /// Server returned an error status.
296    ServerError(u16, String),
297    /// Response body could not be read.
298    ReadError(String),
299    /// Request was malformed.
300    InvalidRequest(String),
301    /// All retries exhausted.
302    RetriesExhausted {
303        /// Attempts made, including the first.
304        attempts: u32,
305    },
306}
307
308impl std::fmt::Display for HttpError {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match self {
311            Self::ConnectionFailed(s) => write!(f, "Connection failed: {}", s),
312            Self::DnsFailure(s)       => write!(f, "DNS failure: {}", s),
313            Self::TlsError(s)         => write!(f, "TLS error: {}", s),
314            Self::ServerError(c, s)   => write!(f, "HTTP {}: {}", c, s),
315            Self::ReadError(s)        => write!(f, "Read error: {}", s),
316            Self::InvalidRequest(s)   => write!(f, "Invalid request: {}", s),
317            Self::RetriesExhausted { attempts } => write!(f, "Failed after {} attempts", attempts),
318        }
319    }
320}
321
322// ── CacheEntry ────────────────────────────────────────────────────────────────
323
324#[derive(Debug, Clone)]
325struct CacheEntry {
326    response:    HttpResponse,
327    etag:        Option<String>,
328    last_modified: Option<String>,
329    stored_at:   Instant,
330    ttl:         Duration,
331}
332
333impl CacheEntry {
334    fn is_fresh(&self) -> bool {
335        self.stored_at.elapsed() < self.ttl
336    }
337}
338
339// ── RateLimiter ───────────────────────────────────────────────────────────────
340
341/// Token-bucket rate limiter per base URL.
342#[derive(Debug, Clone)]
343pub struct RateLimiter {
344    /// Max requests per window.
345    pub limit:   u32,
346    /// Window size in seconds.
347    pub window:  f32,
348    /// Tokens currently available.
349    tokens:      f32,
350    last_refill: Option<Instant>,
351}
352
353impl RateLimiter {
354    /// Allow `limit` requests per `window_secs` seconds, starting full.
355    pub fn new(limit: u32, window_secs: f32) -> Self {
356        Self { limit, window: window_secs, tokens: limit as f32, last_refill: None }
357    }
358
359    /// Take one token if available; returns false if the caller must wait.
360    pub fn try_consume(&mut self) -> bool {
361        self.refill();
362        if self.tokens >= 1.0 {
363            self.tokens -= 1.0;
364            true
365        } else {
366            false
367        }
368    }
369
370    fn refill(&mut self) {
371        let now = Instant::now();
372        if let Some(last) = self.last_refill {
373            let elapsed = last.elapsed().as_secs_f32();
374            let rate = self.limit as f32 / self.window.max(1e-3);
375            self.tokens = (self.tokens + rate * elapsed).min(self.limit as f32);
376        }
377        self.last_refill = Some(now);
378    }
379
380    /// Seconds until the next token is available.
381    pub fn wait_time(&self) -> f32 {
382        if self.tokens >= 1.0 { return 0.0; }
383        let rate = self.limit as f32 / self.window.max(1e-3);
384        (1.0 - self.tokens) / rate.max(1e-6)
385    }
386}
387
388// ── InFlightRequest ───────────────────────────────────────────────────────────
389
390#[derive(Debug)]
391struct InFlightRequest {
392    request:        HttpRequest,
393    attempt:        u32,
394    started:        Instant,
395    retry_after:    Option<Instant>,
396}
397
398/// What a background request thread reports back.
399type Outcome = Result<HttpResponse, HttpError>;
400
401/// Message from a request thread: request id, attempt number, outcome.
402type Report = (RequestId, u32, Outcome);
403
404// ── HttpClient ────────────────────────────────────────────────────────────────
405
406/// Non-blocking HTTP client driven by `tick()`.
407///
408/// `send` queues a request; `tick` starts queued requests (up to
409/// `max_concurrent`, honouring rate limits and retry backoff), collects the
410/// finished ones, retries connection errors, timeouts and 5xx responses with
411/// exponential backoff, and fills the event queue. Successful `GET`
412/// responses are cached and, once stale, revalidated with `If-None-Match` /
413/// `If-Modified-Since`.
414pub struct HttpClient {
415    /// Pending requests not yet dispatched.
416    queue:        Vec<InFlightRequest>,
417    /// Requests currently in-flight.
418    in_flight:    Vec<InFlightRequest>,
419    /// Response cache keyed by URL.
420    cache:        HashMap<String, CacheEntry>,
421    /// Rate limiters keyed by base URL (scheme + host).
422    rate_limiters: HashMap<String, RateLimiter>,
423    /// Completed events to be drained.
424    events:       VecDeque<HttpEvent>,
425    results_tx:   std::sync::mpsc::Sender<Report>,
426    results_rx:   std::sync::mpsc::Receiver<Report>,
427    /// Default cache TTL.
428    pub cache_ttl: Duration,
429    /// Maximum simultaneous connections.
430    pub max_concurrent: usize,
431    /// Log each dispatched request through the `log` crate at debug level.
432    pub verbose:   bool,
433    /// Global headers added to every request.
434    pub default_headers: HashMap<String, String>,
435}
436
437impl HttpClient {
438    /// A client with a 60 s cache TTL and at most 6 requests in flight.
439    pub fn new() -> Self {
440        let (results_tx, results_rx) = std::sync::mpsc::channel();
441        Self {
442            queue:           Vec::new(),
443            in_flight:       Vec::new(),
444            cache:           HashMap::new(),
445            rate_limiters:   HashMap::new(),
446            events:          VecDeque::new(),
447            results_tx,
448            results_rx,
449            cache_ttl:       Duration::from_secs(60),
450            max_concurrent:  6,
451            verbose:         false,
452            default_headers: HashMap::new(),
453        }
454    }
455
456    /// Submit a request. Returns the RequestId for tracking.
457    pub fn send(&mut self, mut request: HttpRequest) -> RequestId {
458        let id = request.id;
459
460        // Apply default headers
461        for (k, v) in &self.default_headers {
462            request.headers.entry(k.clone()).or_insert_with(|| v.clone());
463        }
464
465        // A fresh cache hit is served at once (UseCache); otherwise a cached
466        // copy is revalidated. Before 0.3.0 a UseCache request was served
467        // from the cache however stale it was.
468        let cacheable = request.method == Method::Get
469            && matches!(request.cache_policy, CachePolicy::UseCache | CachePolicy::Revalidate);
470        if cacheable {
471            if let Some(entry) = self.cache.get(&request.url) {
472                if entry.is_fresh() && request.cache_policy == CachePolicy::UseCache {
473                    let mut resp = entry.response.clone();
474                    resp.from_cache = true;
475                    self.events.push_back(HttpEvent::Success { id, response: resp });
476                    return id;
477                }
478                if let Some(etag) = &entry.etag {
479                    request.headers.insert("If-None-Match".into(), etag.clone());
480                }
481                if let Some(lm) = &entry.last_modified {
482                    request.headers.insert("If-Modified-Since".into(), lm.clone());
483                }
484            }
485        }
486
487        self.queue.push(InFlightRequest {
488            request,
489            attempt: 0,
490            started: Instant::now(),
491            retry_after: None,
492        });
493
494        id
495    }
496
497    /// Cancel all requests with the given tag.
498    pub fn cancel_by_tag(&mut self, tag: &str) {
499        let cancelled: Vec<RequestId> = self.queue.iter()
500            .chain(self.in_flight.iter())
501            .filter(|r| r.request.tag.as_deref() == Some(tag))
502            .map(|r| r.request.id)
503            .collect();
504        for id in cancelled {
505            self.events.push_back(HttpEvent::Cancelled { id });
506        }
507        self.queue.retain(|r| r.request.tag.as_deref() != Some(tag));
508        self.in_flight.retain(|r| r.request.tag.as_deref() != Some(tag));
509    }
510
511    /// Set a default rate limiter for a base URL.
512    pub fn set_rate_limit(&mut self, base_url: &str, limit: u32, window_secs: f32) {
513        self.rate_limiters.insert(base_url.to_owned(), RateLimiter::new(limit, window_secs));
514    }
515
516    /// Set a default header on all outgoing requests.
517    pub fn set_default_header(&mut self, key: impl Into<String>, val: impl Into<String>) {
518        self.default_headers.insert(key.into(), val.into());
519    }
520
521    /// Drive the client state machine. Call once per frame.
522    pub fn tick(&mut self, _dt: f32) {
523        let now = Instant::now();
524        // Highest priority first; the sort is stable, so equal priorities
525        // keep their FIFO order.
526        self.queue.sort_by_key(|r| -r.request.priority);
527
528        // Start queued requests whose backoff has passed. (Before 0.3.0
529        // `retry_after` was set but never checked, so retries went out at
530        // once.)
531        let mut i = 0;
532        while self.in_flight.len() < self.max_concurrent && i < self.queue.len() {
533            if self.queue[i].retry_after.is_some_and(|t| t > now) {
534                i += 1;
535                continue;
536            }
537            let mut req = self.queue.remove(i);
538
539            let base = base_url(&req.request.url);
540            if let Some(limiter) = self.rate_limiters.get_mut(&base) {
541                if !limiter.try_consume() {
542                    let wait_ms = (limiter.wait_time() * 1000.0).ceil() as u64;
543                    self.events.push_back(HttpEvent::RateLimited {
544                        id: req.request.id,
545                        delay_ms: wait_ms,
546                    });
547                    req.retry_after = Some(now + Duration::from_millis(wait_ms));
548                    self.queue.push(req);
549                    continue;
550                }
551            }
552
553            req.started = now;
554            if self.verbose {
555                log::debug!(
556                    "http {} {} (attempt {})",
557                    req.request.method.as_str(),
558                    req.request.url,
559                    req.attempt + 1
560                );
561            }
562            dispatch(&req.request, req.attempt, self.results_tx.clone());
563            self.in_flight.push(req);
564        }
565
566        // Collect finished requests.
567        while let Ok((id, attempt, outcome)) = self.results_rx.try_recv() {
568            let Some(pos) = self
569                .in_flight
570                .iter()
571                .position(|r| r.request.id == id && r.attempt == attempt)
572            else {
573                continue; // cancelled, or a late answer to an attempt that timed out
574            };
575            let req = self.in_flight.remove(pos);
576            match outcome {
577                Ok(response)
578                    if response.is_server_error() && req.attempt < req.request.max_retries =>
579                {
580                    self.retry(req);
581                }
582                Ok(response) => self.finish(req, response),
583                Err(HttpError::InvalidRequest(msg)) => {
584                    self.events.push_back(HttpEvent::Failure {
585                        id,
586                        url: req.request.url.clone(),
587                        error: HttpError::InvalidRequest(msg),
588                    });
589                }
590                Err(err) if req.attempt < req.request.max_retries => {
591                    if self.verbose {
592                        log::debug!("http {}: {err}, retrying", req.request.url);
593                    }
594                    self.retry(req);
595                }
596                Err(err) => {
597                    let error = if req.request.max_retries == 0 {
598                        err
599                    } else {
600                        HttpError::RetriesExhausted { attempts: req.attempt + 1 }
601                    };
602                    self.events.push_back(HttpEvent::Failure {
603                        id,
604                        url: req.request.url.clone(),
605                        error,
606                    });
607                }
608            }
609        }
610
611        // Time out requests that have not answered.
612        let mut k = 0;
613        while k < self.in_flight.len() {
614            if self.in_flight[k].started.elapsed() > self.in_flight[k].request.timeout {
615                let req = self.in_flight.remove(k);
616                if req.attempt < req.request.max_retries {
617                    self.retry(req);
618                } else {
619                    self.events.push_back(HttpEvent::Timeout {
620                        id: req.request.id,
621                        url: req.request.url.clone(),
622                    });
623                }
624            } else {
625                k += 1;
626            }
627        }
628    }
629
630    fn retry(&mut self, req: InFlightRequest) {
631        let backoff = backoff_duration(req.attempt);
632        self.queue.push(InFlightRequest {
633            attempt: req.attempt + 1,
634            started: Instant::now(),
635            retry_after: Some(Instant::now() + backoff),
636            ..req
637        });
638    }
639
640    fn finish(&mut self, req: InFlightRequest, mut response: HttpResponse) {
641        let id = req.request.id;
642        let get = req.request.method == Method::Get;
643        if response.is_not_modified() && get {
644            // Revalidated: serve the cached copy and restart its TTL.
645            if let Some(entry) = self.cache.get_mut(&req.request.url) {
646                entry.stored_at = Instant::now();
647                let latency = response.latency;
648                response = entry.response.clone();
649                response.latency = latency;
650                response.from_cache = true;
651            }
652        } else if response.is_success() && get && req.request.cache_policy != CachePolicy::NoStore {
653            self.cache.insert(req.request.url.clone(), CacheEntry {
654                etag:          response.etag().map(|s| s.to_owned()),
655                last_modified: response.last_modified().map(|s| s.to_owned()),
656                stored_at:     Instant::now(),
657                ttl:           self.cache_ttl,
658                response:      response.clone(),
659            });
660        }
661        self.events.push_back(HttpEvent::Success { id, response });
662    }
663
664    /// Drain all completed events.
665    pub fn drain_events(&mut self) -> impl Iterator<Item = HttpEvent> + '_ {
666        self.events.drain(..)
667    }
668
669    /// Number of pending + in-flight requests.
670    pub fn pending_count(&self) -> usize {
671        self.queue.len() + self.in_flight.len()
672    }
673
674    /// Clear the response cache.
675    pub fn clear_cache(&mut self) { self.cache.clear(); }
676
677    /// Remove cache entries older than their TTL.
678    pub fn evict_stale_cache(&mut self) {
679        self.cache.retain(|_, entry| entry.is_fresh());
680    }
681}
682
683impl Default for HttpClient {
684    fn default() -> Self { Self::new() }
685}
686
687// ── Network backend ───────────────────────────────────────────────────────────
688
689/// Run `request` on a background thread and report back on `tx`.
690fn dispatch(request: &HttpRequest, attempt: u32, tx: std::sync::mpsc::Sender<Report>) {
691    let request = request.clone();
692    let spawned = std::thread::Builder::new()
693        .name("proof-http".into())
694        .spawn({
695            let tx = tx.clone();
696            let request = request.clone();
697            move || {
698                let outcome = perform(&request);
699                let _ = tx.send((request.id, attempt, outcome));
700            }
701        });
702    if let Err(e) = spawned {
703        let _ = tx.send((
704            request.id,
705            attempt,
706            Err(HttpError::ConnectionFailed(format!("could not start a request thread: {e}"))),
707        ));
708    }
709}
710
711#[cfg(feature = "http")]
712fn perform(request: &HttpRequest) -> Outcome {
713    use ureq::http;
714    let started = Instant::now();
715    let agent: ureq::Agent = ureq::Agent::config_builder()
716        .timeout_global(Some(request.timeout))
717        .http_status_as_error(false)
718        .build()
719        .into();
720    let mut builder = http::Request::builder()
721        .method(request.method.as_str())
722        .uri(&request.url);
723    for (k, v) in &request.headers {
724        builder = builder.header(k.as_str(), v.as_str());
725    }
726    let result = match &request.body {
727        Some(body) => builder
728            .body(body.clone())
729            .map_err(|e| HttpError::InvalidRequest(e.to_string()))
730            .and_then(|r| agent.run(r).map_err(map_ureq_error)),
731        None => builder
732            .body(())
733            .map_err(|e| HttpError::InvalidRequest(e.to_string()))
734            .and_then(|r| agent.run(r).map_err(map_ureq_error)),
735    };
736    let mut resp = result?;
737    let status = resp.status().as_u16();
738    let headers: HashMap<String, String> = resp
739        .headers()
740        .iter()
741        .filter_map(|(k, v)| {
742            v.to_str()
743                .ok()
744                .map(|v| (k.as_str().to_ascii_lowercase(), v.to_owned()))
745        })
746        .collect();
747    let body = resp
748        .body_mut()
749        .with_config()
750        .limit(64 * 1024 * 1024)
751        .read_to_vec()
752        .map_err(|e| HttpError::ReadError(e.to_string()))?;
753    let text = String::from_utf8(body.clone()).ok();
754    Ok(HttpResponse {
755        status,
756        headers,
757        body,
758        text,
759        latency: started.elapsed(),
760        from_cache: false,
761    })
762}
763
764#[cfg(feature = "http")]
765fn map_ureq_error(e: ureq::Error) -> HttpError {
766    match e {
767        ureq::Error::HostNotFound => HttpError::DnsFailure(e.to_string()),
768        ureq::Error::BadUri(_) | ureq::Error::Http(_) => HttpError::InvalidRequest(e.to_string()),
769        ureq::Error::Rustls(_) => HttpError::TlsError(e.to_string()),
770        other => HttpError::ConnectionFailed(other.to_string()),
771    }
772}
773
774#[cfg(not(feature = "http"))]
775fn perform(_request: &HttpRequest) -> Outcome {
776    Err(HttpError::InvalidRequest(
777        "proof-engine was built without the `http` feature, so it cannot make network requests"
778            .into(),
779    ))
780}
781
782// ── Helpers ───────────────────────────────────────────────────────────────────
783
784fn base_url(url: &str) -> String {
785    // Extract scheme + host (e.g. "https://example.com")
786    if let Some(after_scheme) = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://")) {
787        let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
788        let scheme = if url.starts_with("https") { "https" } else { "http" };
789        format!("{}://{}", scheme, &after_scheme[..host_end])
790    } else {
791        url.to_owned()
792    }
793}
794
795fn backoff_duration(attempt: u32) -> Duration {
796    // Exponential backoff: 200ms, 400ms, 800ms, 1600ms, cap at 30s
797    let base_ms = 200u64 * (1u64 << attempt.min(7));
798    // Add ±25% jitter
799    let jitter = simple_hash(attempt as u64) % (base_ms / 4).max(1);
800    Duration::from_millis((base_ms + jitter).min(30_000))
801}
802
803fn simple_hash(n: u64) -> u64 {
804    let mut x = n ^ (n >> 33);
805    x = x.wrapping_mul(0xff51afd7ed558ccd);
806    x ^= x >> 33;
807    x = x.wrapping_mul(0xc4ceb9fe1a85ec53);
808    x ^= x >> 33;
809    x
810}
811
812// ── Tests ─────────────────────────────────────────────────────────────────────
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    fn wait_events(client: &mut HttpClient, want: usize) -> Vec<HttpEvent> {
819        let deadline = Instant::now() + Duration::from_secs(10);
820        let mut out = Vec::new();
821        while out.len() < want && Instant::now() < deadline {
822            client.tick(0.016);
823            out.extend(client.drain_events());
824            std::thread::sleep(Duration::from_millis(5));
825        }
826        out
827    }
828
829    #[test]
830    fn json_field_handles_escapes_and_nesting() {
831        let resp = HttpResponse {
832            status: 200,
833            headers: HashMap::new(),
834            body: br#"{"name":"A \"quoted\" name, with comma","rank":3,"meta":{"rank":99},"gone":null}"#.to_vec(),
835            text: None,
836            latency: Duration::ZERO,
837            from_cache: false,
838        };
839        // The old substring parser cut the name at the escaped quote.
840        assert_eq!(resp.json_field("name").as_deref(), Some("A \"quoted\" name, with comma"));
841        assert_eq!(resp.json_field("rank").as_deref(), Some("3"));
842        assert_eq!(resp.json_field("gone"), None);
843        assert_eq!(resp.json_field("missing"), None);
844    }
845
846    #[test]
847    fn retry_after_is_honoured() {
848        let mut client = HttpClient::new();
849        let mut req = HttpRequest::get("http://127.0.0.1:9/never");
850        req.max_retries = 0;
851        client.queue.push(InFlightRequest {
852            request: req,
853            attempt: 1,
854            started: Instant::now(),
855            retry_after: Some(Instant::now() + Duration::from_secs(3600)),
856        });
857        client.tick(0.016);
858        assert_eq!(client.in_flight.len(), 0, "a request in backoff must stay queued");
859        assert_eq!(client.queue.len(), 1);
860    }
861
862    #[cfg(not(feature = "http"))]
863    #[test]
864    fn without_feature_requests_fail_instead_of_faking_success() {
865        let mut client = HttpClient::new();
866        client.send(HttpRequest::get("http://127.0.0.1:9/"));
867        let events = wait_events(&mut client, 1);
868        assert!(matches!(
869            events.first(),
870            Some(HttpEvent::Failure { error: HttpError::InvalidRequest(_), .. })
871        ), "{events:?}");
872    }
873
874    /// A tiny HTTP/1.1 server answering each connection with the next
875    /// canned response, recording the request heads it saw.
876    #[cfg(feature = "http")]
877    fn serve(responses: Vec<&'static str>) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
878        use std::io::{BufRead, BufReader, Read, Write};
879        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
880        let url = format!("http://{}", listener.local_addr().unwrap());
881        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
882        let seen2 = seen.clone();
883        std::thread::spawn(move || {
884            for resp in responses {
885                let Ok((stream, _)) = listener.accept() else { return };
886                let mut reader = BufReader::new(stream.try_clone().unwrap());
887                let mut head = String::new();
888                let mut len = 0usize;
889                loop {
890                    let mut line = String::new();
891                    if reader.read_line(&mut line).unwrap_or(0) == 0 { break; }
892                    if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
893                        len = v.trim().parse().unwrap_or(0);
894                    }
895                    if line == "\r\n" { break; }
896                    head.push_str(&line);
897                }
898                let mut body = vec![0u8; len];
899                let _ = reader.read_exact(&mut body);
900                seen2.lock().unwrap().push(head);
901                let mut stream = stream;
902                let _ = stream.write_all(resp.as_bytes());
903            }
904        });
905        (url, seen)
906    }
907
908    #[cfg(feature = "http")]
909    #[test]
910    fn real_get_returns_server_body() {
911        let (url, seen) = serve(vec![
912            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\n{\"rank\": 42}\n",
913        ]);
914        let mut client = HttpClient::new();
915        let id = client.send(HttpRequest::get(format!("{url}/scores")).with_header("X-Test", "1"));
916        let events = wait_events(&mut client, 1);
917        match &events[..] {
918            [HttpEvent::Success { id: got, response }] => {
919                assert_eq!(*got, id);
920                assert_eq!(response.status, 200);
921                assert_eq!(response.json_field("rank").as_deref(), Some("42"));
922                assert!(!response.from_cache);
923            }
924            other => panic!("{other:?}"),
925        }
926        let head = &seen.lock().unwrap()[0];
927        assert!(head.starts_with("GET /scores HTTP/1.1"), "{head}");
928        assert!(head.to_ascii_lowercase().contains("x-test: 1"), "{head}");
929    }
930
931    #[cfg(feature = "http")]
932    #[test]
933    fn server_error_is_retried_then_succeeds() {
934        let (url, seen) = serve(vec![
935            "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
936            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
937        ]);
938        let mut client = HttpClient::new();
939        client.send(HttpRequest::post(format!("{url}/submit"), b"{}".to_vec()).with_retries(2));
940        let events = wait_events(&mut client, 1);
941        match &events[..] {
942            [HttpEvent::Success { response, .. }] => assert_eq!(response.text_body(), "ok"),
943            other => panic!("{other:?}"),
944        }
945        assert_eq!(seen.lock().unwrap().len(), 2);
946    }
947
948    #[cfg(feature = "http")]
949    #[test]
950    fn stale_cache_is_revalidated_with_etag() {
951        let (url, seen) = serve(vec![
952            "HTTP/1.1 200 OK\r\nETag: \"v1\"\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello",
953            "HTTP/1.1 304 Not Modified\r\nETag: \"v1\"\r\nConnection: close\r\n\r\n",
954        ]);
955        let mut client = HttpClient::new();
956        client.cache_ttl = Duration::ZERO; // every entry is stale at once
957        client.send(HttpRequest::get(format!("{url}/a")));
958        assert_eq!(wait_events(&mut client, 1).len(), 1);
959        client.send(HttpRequest::get(format!("{url}/a")));
960        let events = wait_events(&mut client, 1);
961        match &events[..] {
962            [HttpEvent::Success { response, .. }] => {
963                assert_eq!(response.text_body(), "hello");
964                assert!(response.from_cache);
965            }
966            other => panic!("{other:?}"),
967        }
968        let heads = seen.lock().unwrap();
969        assert!(heads[1].to_ascii_lowercase().contains("if-none-match: \"v1\""), "{}", heads[1]);
970    }
971
972    #[cfg(feature = "http")]
973    #[test]
974    fn connection_refused_fails_after_retries() {
975        // Bind then drop a listener so the port is very likely closed.
976        let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
977        let mut client = HttpClient::new();
978        client.send(HttpRequest::get(format!("http://127.0.0.1:{port}/")).with_retries(1));
979        let events = wait_events(&mut client, 1);
980        assert!(matches!(
981            events.first(),
982            Some(HttpEvent::Failure { error: HttpError::RetriesExhausted { attempts: 2 }, .. })
983        ), "{events:?}");
984    }
985}