1#![warn(missing_docs)]
22
23use std::collections::{HashMap, VecDeque};
24use std::time::{Duration, Instant};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Method {
31 Get,
33 Post,
35 Put,
37 Delete,
39 Patch,
41 Head,
43 Options,
45}
46
47impl Method {
48 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#[derive(Debug, Clone)]
66pub struct HttpRequest {
67 pub id: RequestId,
69 pub method: Method,
71 pub url: String,
73 pub headers: HashMap<String, String>,
75 pub body: Option<Vec<u8>>,
77 pub timeout: Duration,
79 pub max_retries: u32,
81 pub priority: i32,
83 pub tag: Option<String>,
85 pub cache_policy: CachePolicy,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
91pub enum CachePolicy {
92 NoStore,
94 UseCache,
96 Revalidate,
98 NoCache,
100}
101
102impl HttpRequest {
103 pub fn get(url: impl Into<String>) -> Self {
105 Self::new(Method::Get, url)
106 }
107
108 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 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 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 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 pub fn with_timeout(mut self, t: Duration) -> Self { self.timeout = t; self }
147 pub fn with_retries(mut self, n: u32) -> Self { self.max_retries = n; self }
149 pub fn with_priority(mut self, p: i32) -> Self { self.priority = p; self }
151 pub fn with_tag(mut self, t: impl Into<String>) -> Self { self.tag = Some(t.into()); self }
153 pub fn with_cache(mut self, p: CachePolicy) -> Self { self.cache_policy = p; self }
155
156 pub fn bearer_auth(self, token: impl Into<String>) -> Self {
158 self.with_header("Authorization", format!("Bearer {}", token.into()))
159 }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub struct RequestId(pub u64);
167
168impl RequestId {
169 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#[derive(Debug, Clone)]
181pub struct HttpResponse {
182 pub status: u16,
184 pub headers: HashMap<String, String>,
186 pub body: Vec<u8>,
188 pub text: Option<String>,
190 pub latency: Duration,
192 pub from_cache: bool,
194}
195
196impl HttpResponse {
197 pub fn is_success(&self) -> bool { (200..300).contains(&self.status) }
199 pub fn is_client_error(&self) -> bool { (400..500).contains(&self.status) }
201 pub fn is_server_error(&self) -> bool { (500..600).contains(&self.status) }
203 pub fn is_not_modified(&self) -> bool { self.status == 304 }
205
206 pub fn content_type(&self) -> Option<&str> {
208 self.headers.get("content-type").map(|s| s.as_str())
209 }
210
211 pub fn etag(&self) -> Option<&str> {
213 self.headers.get("etag").map(|s| s.as_str())
214 }
215
216 pub fn last_modified(&self) -> Option<&str> {
218 self.headers.get("last-modified").map(|s| s.as_str())
219 }
220
221 pub fn text_body(&self) -> &str {
223 self.text.as_deref().unwrap_or("")
224 }
225
226 pub fn json(&self) -> Result<serde_json::Value, serde_json::Error> {
228 serde_json::from_slice(&self.body)
229 }
230
231 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#[derive(Debug, Clone)]
248pub enum HttpEvent {
249 Success {
251 id: RequestId,
253 response: HttpResponse,
255 },
256 Failure {
258 id: RequestId,
260 error: HttpError,
262 url: String,
264 },
265 Timeout {
267 id: RequestId,
269 url: String,
271 },
272 Cancelled {
274 id: RequestId,
276 },
277 RateLimited {
279 id: RequestId,
281 delay_ms: u64,
283 },
284}
285
286#[derive(Debug, Clone)]
288pub enum HttpError {
289 ConnectionFailed(String),
291 DnsFailure(String),
293 TlsError(String),
295 ServerError(u16, String),
297 ReadError(String),
299 InvalidRequest(String),
301 RetriesExhausted {
303 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#[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#[derive(Debug, Clone)]
343pub struct RateLimiter {
344 pub limit: u32,
346 pub window: f32,
348 tokens: f32,
350 last_refill: Option<Instant>,
351}
352
353impl RateLimiter {
354 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 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 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#[derive(Debug)]
391struct InFlightRequest {
392 request: HttpRequest,
393 attempt: u32,
394 started: Instant,
395 retry_after: Option<Instant>,
396}
397
398type Outcome = Result<HttpResponse, HttpError>;
400
401type Report = (RequestId, u32, Outcome);
403
404pub struct HttpClient {
415 queue: Vec<InFlightRequest>,
417 in_flight: Vec<InFlightRequest>,
419 cache: HashMap<String, CacheEntry>,
421 rate_limiters: HashMap<String, RateLimiter>,
423 events: VecDeque<HttpEvent>,
425 results_tx: std::sync::mpsc::Sender<Report>,
426 results_rx: std::sync::mpsc::Receiver<Report>,
427 pub cache_ttl: Duration,
429 pub max_concurrent: usize,
431 pub verbose: bool,
433 pub default_headers: HashMap<String, String>,
435}
436
437impl HttpClient {
438 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 pub fn send(&mut self, mut request: HttpRequest) -> RequestId {
458 let id = request.id;
459
460 for (k, v) in &self.default_headers {
462 request.headers.entry(k.clone()).or_insert_with(|| v.clone());
463 }
464
465 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 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 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 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 pub fn tick(&mut self, _dt: f32) {
523 let now = Instant::now();
524 self.queue.sort_by_key(|r| -r.request.priority);
527
528 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 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; };
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 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 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 pub fn drain_events(&mut self) -> impl Iterator<Item = HttpEvent> + '_ {
666 self.events.drain(..)
667 }
668
669 pub fn pending_count(&self) -> usize {
671 self.queue.len() + self.in_flight.len()
672 }
673
674 pub fn clear_cache(&mut self) { self.cache.clear(); }
676
677 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
687fn 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
782fn base_url(url: &str) -> String {
785 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 let base_ms = 200u64 * (1u64 << attempt.min(7));
798 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#[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 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 #[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; 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 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}