1use std::io;
30use std::pin::Pin;
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33use futures_util::{Stream, StreamExt};
34use reqwest::header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
35use reqwest::{Method, StatusCode};
36use serde_json::Value;
37
38use crate::runnables::CancellationToken;
39use crate::ssrf::read_body_bounded;
40
41pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
43pub const DEFAULT_API_TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
45pub const DEFAULT_API_MAX_BYTES: usize = 10 * 1024 * 1024;
47pub const DEFAULT_SSE_TOTAL_TIMEOUT: Duration = Duration::from_secs(300);
49pub const ERROR_BODY_BYTES: usize = 64 * 1024;
51
52#[derive(Debug, thiserror::Error)]
54#[non_exhaustive]
55pub enum HttpError {
56 #[error("failed to build HTTP client: {0}")]
58 Build(String),
59 #[error("invalid URL {url:?}: {reason}")]
61 InvalidUrl {
62 url: String,
64 reason: String,
66 },
67 #[error("HTTP transport error: {0}")]
69 Transport(String),
70 #[error("HTTP operation timed out after {0:?}")]
72 Timeout(Duration),
73 #[error("HTTP operation cancelled")]
75 Cancelled,
76 #[error("response body exceeded the {limit} byte limit")]
78 BodyTooLarge {
79 limit: usize,
81 },
82 #[error("HTTP status {status}: {body}")]
84 Status {
85 status: u16,
87 body: String,
89 },
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum TransportRetryMode {
99 AllTransportErrors,
102 PreDispatchOnly,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct RetryPolicy {
110 pub max_attempts: usize,
112 pub base_delay: Duration,
114 pub max_delay: Duration,
117 pub transport: TransportRetryMode,
119}
120
121impl Default for RetryPolicy {
122 fn default() -> Self {
123 Self {
124 max_attempts: 3,
125 base_delay: Duration::from_millis(500),
126 max_delay: Duration::from_secs(30),
127 transport: TransportRetryMode::AllTransportErrors,
128 }
129 }
130}
131
132impl RetryPolicy {
133 pub fn none() -> Self {
135 Self {
136 max_attempts: 1,
137 ..Default::default()
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum Profile {
145 Api,
147 Sse,
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct RequestOptions {
154 bearer: Option<String>,
155 headers: HeaderMap,
156 retry_mode: Option<TransportRetryMode>,
160}
161
162impl RequestOptions {
163 pub fn new() -> Self {
165 Self::default()
166 }
167
168 pub fn bearer(mut self, token: impl Into<String>) -> Self {
170 self.bearer = Some(token.into());
171 self
172 }
173
174 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
177 self.headers.insert(name, value);
178 self
179 }
180
181 pub fn retry_mode(mut self, mode: TransportRetryMode) -> Self {
190 self.retry_mode = Some(mode);
191 self
192 }
193}
194
195#[derive(Debug, Clone)]
198pub struct HttpClientBuilder {
199 profile: Profile,
200 connect_timeout: Duration,
201 total_timeout: Duration,
202 max_bytes: usize,
203 retry: RetryPolicy,
204 bearer: Option<String>,
205 user_agent: Option<String>,
206 default_headers: HeaderMap,
207 use_system_proxy: bool,
208 cancel: Option<CancellationToken>,
209}
210
211impl HttpClientBuilder {
212 fn new(profile: Profile) -> Self {
213 let (total_timeout, max_bytes) = match profile {
214 Profile::Api => (DEFAULT_API_TOTAL_TIMEOUT, DEFAULT_API_MAX_BYTES),
215 Profile::Sse => (DEFAULT_SSE_TOTAL_TIMEOUT, usize::MAX),
216 };
217 Self {
218 profile,
219 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
220 total_timeout,
221 max_bytes,
222 retry: RetryPolicy::default(),
223 bearer: None,
224 user_agent: None,
225 default_headers: HeaderMap::new(),
226 use_system_proxy: false,
230 cancel: None,
231 }
232 }
233
234 pub fn timeouts(mut self, connect: Duration, total: Duration) -> Self {
236 self.connect_timeout = connect;
237 self.total_timeout = total;
238 self
239 }
240
241 pub fn max_bytes(mut self, max_bytes: usize) -> Self {
243 self.max_bytes = max_bytes;
244 self
245 }
246
247 pub fn retry(mut self, policy: RetryPolicy) -> Self {
249 self.retry = policy;
250 self
251 }
252
253 pub fn bearer(mut self, token: impl Into<String>) -> Self {
255 self.bearer = Some(token.into());
256 self
257 }
258
259 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
261 self.user_agent = Some(user_agent.into());
262 self
263 }
264
265 pub fn default_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
267 self.default_headers.insert(name, value);
268 self
269 }
270
271 pub fn use_system_proxy(mut self) -> Self {
273 self.use_system_proxy = true;
274 self
275 }
276
277 pub fn cancellation_token(mut self, token: CancellationToken) -> Self {
279 self.cancel = Some(token);
280 self
281 }
282
283 pub fn build(self) -> Result<HttpClient, HttpError> {
285 let mut builder = reqwest::Client::builder()
286 .connect_timeout(self.connect_timeout)
287 .redirect(reqwest::redirect::Policy::none());
290 if !self.use_system_proxy {
291 builder = builder.no_proxy();
292 }
293 if let Some(ua) = &self.user_agent {
294 builder = builder.user_agent(ua);
295 }
296 let client = builder
297 .build()
298 .map_err(|e| HttpError::Build(e.to_string()))?;
299
300 Ok(HttpClient {
301 client,
302 profile: self.profile,
303 total_timeout: self.total_timeout,
304 max_bytes: self.max_bytes,
305 retry: self.retry,
306 bearer: self.bearer,
307 default_headers: self.default_headers,
308 cancel: self.cancel,
309 })
310 }
311}
312
313#[derive(Debug, Clone)]
315pub struct HttpClient {
316 client: reqwest::Client,
317 profile: Profile,
318 total_timeout: Duration,
319 max_bytes: usize,
320 retry: RetryPolicy,
321 bearer: Option<String>,
322 default_headers: HeaderMap,
323 cancel: Option<CancellationToken>,
324}
325
326impl HttpClient {
327 pub fn api() -> HttpClientBuilder {
329 HttpClientBuilder::new(Profile::Api)
330 }
331
332 pub fn sse() -> HttpClientBuilder {
334 HttpClientBuilder::new(Profile::Sse)
335 }
336
337 pub fn builder(profile: Profile) -> HttpClientBuilder {
339 HttpClientBuilder::new(profile)
340 }
341
342 pub fn profile(&self) -> Profile {
344 self.profile
345 }
346
347 pub async fn get(&self, url: &str) -> Result<BoundedResponse, HttpError> {
350 self.get_with(url, RequestOptions::new()).await
351 }
352
353 pub async fn get_with(
355 &self,
356 url: &str,
357 opts: RequestOptions,
358 ) -> Result<BoundedResponse, HttpError> {
359 let (resp, deadline) = self.execute(Method::GET, url, None, &opts).await?;
360 self.read_bounded_within(resp, deadline).await
361 }
362
363 pub async fn post_json(&self, url: &str, body: &Value) -> Result<BoundedResponse, HttpError> {
365 self.post_json_with(url, body, RequestOptions::new()).await
366 }
367
368 pub async fn post_json_with(
370 &self,
371 url: &str,
372 body: &Value,
373 opts: RequestOptions,
374 ) -> Result<BoundedResponse, HttpError> {
375 let (resp, deadline) = self.execute(Method::POST, url, Some(body), &opts).await?;
376 self.read_bounded_within(resp, deadline).await
377 }
378
379 pub async fn post_multipart_with(
387 &self,
388 url: &str,
389 form: reqwest::multipart::Form,
390 opts: RequestOptions,
391 ) -> Result<BoundedResponse, HttpError> {
392 if self
393 .cancel
394 .as_ref()
395 .is_some_and(CancellationToken::is_cancelled)
396 {
397 return Err(HttpError::Cancelled);
398 }
399 let deadline = tokio::time::Instant::now() + self.total_timeout;
400 let request = self
401 .client
402 .request(Method::POST, url)
403 .multipart(form)
404 .headers(self.compose_headers(&opts));
405 let remaining = deadline
406 .checked_duration_since(tokio::time::Instant::now())
407 .ok_or(HttpError::Timeout(self.total_timeout))?;
408 let resp = match tokio::time::timeout(remaining, request.send()).await {
409 Err(_) => return Err(HttpError::Timeout(self.total_timeout)),
410 Ok(Ok(resp)) => resp,
411 Ok(Err(err)) => {
412 if err.is_timeout() {
413 return Err(HttpError::Timeout(self.total_timeout));
414 }
415 return Err(HttpError::Transport(err.to_string()));
416 }
417 };
418 self.read_bounded_within(resp, deadline).await
419 }
420
421 pub async fn open_sse(
428 &self,
429 url: &str,
430 body: Option<&Value>,
431 opts: RequestOptions,
432 ) -> Result<SseStream, HttpError> {
433 let method = if body.is_some() {
434 Method::POST
435 } else {
436 Method::GET
437 };
438 let (resp, deadline) = self.execute(method, url, body, &opts).await?;
439 let status = resp.status();
440 if !status.is_success() {
441 let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
447 Some(rem) => rem,
448 None => return Err(HttpError::Timeout(self.total_timeout)),
449 };
450 let (text, truncated) =
451 match tokio::time::timeout(remaining, read_body_bounded(resp, ERROR_BODY_BYTES))
452 .await
453 {
454 Ok(Ok(pair)) => pair,
455 Ok(Err(e)) => {
456 return Err(HttpError::Transport(format!("read error body: {e}")));
457 }
458 Err(_elapsed) => return Err(HttpError::Timeout(self.total_timeout)),
459 };
460 let body = if truncated {
461 truncate_for_error(&text)
462 } else {
463 text
464 };
465 return Err(HttpError::Status {
466 status: status.as_u16(),
467 body,
468 });
469 }
470 let stream = resp
471 .bytes_stream()
472 .map(|result| result.map_err(|e| io::Error::other(e.to_string())));
473 Ok(Box::pin(stream))
474 }
475
476 async fn read_bounded_within(
483 &self,
484 resp: reqwest::Response,
485 deadline: tokio::time::Instant,
486 ) -> Result<BoundedResponse, HttpError> {
487 let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
488 Some(remaining) => remaining,
489 None => return Err(HttpError::Timeout(self.total_timeout)),
490 };
491 match tokio::time::timeout(remaining, self.read_bounded(resp)).await {
492 Ok(result) => result,
493 Err(_elapsed) => Err(HttpError::Timeout(self.total_timeout)),
494 }
495 }
496
497 async fn read_bounded(&self, resp: reqwest::Response) -> Result<BoundedResponse, HttpError> {
498 let status = resp.status();
499 let headers = resp.headers().clone();
500 let (body, truncated) = read_body_bounded(resp, self.max_bytes)
501 .await
502 .map_err(|e| HttpError::Transport(format!("read response body: {e}")))?;
503 if truncated {
504 return Err(HttpError::BodyTooLarge {
505 limit: self.max_bytes,
506 });
507 }
508 BoundedResponse {
513 status,
514 headers,
515 body,
516 }
517 .error_for_status()
518 }
519
520 fn compose_headers(&self, opts: &RequestOptions) -> HeaderMap {
523 let mut headers = self.default_headers.clone();
528 for (k, v) in opts.headers.iter() {
529 headers.insert(k.clone(), v.clone());
530 }
531 let bearer = opts.bearer.as_deref().or(self.bearer.as_deref());
532 if let Some(token) = bearer {
533 if let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}")) {
534 headers.insert(reqwest::header::AUTHORIZATION, value);
535 }
536 }
537 headers
538 }
539
540 fn prepare_request(
541 &self,
542 method: Method,
543 url: &str,
544 json: Option<&Value>,
545 opts: &RequestOptions,
546 ) -> reqwest::RequestBuilder {
547 let mut builder = self.client.request(method, url);
548 if let Some(json) = json {
549 builder = builder.json(json);
550 }
551 builder = builder.headers(self.compose_headers(opts));
552 builder
553 }
554
555 async fn execute(
556 &self,
557 method: Method,
558 url: &str,
559 json: Option<&Value>,
560 opts: &RequestOptions,
561 ) -> Result<(reqwest::Response, tokio::time::Instant), HttpError> {
562 let deadline = tokio::time::Instant::now() + self.total_timeout;
563 let retry_mode = effective_retry_mode(&method, opts, self.retry.transport);
567 let mut attempt: usize = 0;
568 loop {
569 if self
570 .cancel
571 .as_ref()
572 .is_some_and(CancellationToken::is_cancelled)
573 {
574 return Err(HttpError::Cancelled);
575 }
576 let remaining = match deadline.checked_duration_since(tokio::time::Instant::now()) {
577 Some(remaining) => remaining,
578 None => return Err(HttpError::Timeout(self.total_timeout)),
579 };
580 let request = self.prepare_request(method.clone(), url, json, opts);
581 match tokio::time::timeout(remaining, request.send()).await {
582 Err(_) => return Err(HttpError::Timeout(self.total_timeout)),
583 Ok(Ok(resp)) => {
584 let status = resp.status();
585 if attempt + 1 < self.retry.max_attempts && is_retryable_status(status) {
586 let retry_after = parse_retry_after(resp.headers());
587 drop(resp);
588 self.wait(attempt, retry_after, deadline).await?;
589 attempt += 1;
590 continue;
591 }
592 return Ok((resp, deadline));
593 }
594 Ok(Err(err)) => {
595 if attempt + 1 < self.retry.max_attempts
596 && is_retryable_transport(&err, retry_mode)
597 {
598 self.wait(attempt, None, deadline).await?;
599 attempt += 1;
600 continue;
601 }
602 if err.is_timeout() {
603 return Err(HttpError::Timeout(self.total_timeout));
604 }
605 return Err(HttpError::Transport(err.to_string()));
606 }
607 }
608 }
609 }
610
611 async fn wait(
612 &self,
613 attempt: usize,
614 retry_after: Option<Duration>,
615 deadline: tokio::time::Instant,
616 ) -> Result<(), HttpError> {
617 let backoff = compute_backoff(attempt, self.retry, random_entropy());
618 let delay = retry_after.map_or(backoff, |ra| ra.max(backoff));
619 let remaining = deadline
620 .checked_duration_since(tokio::time::Instant::now())
621 .unwrap_or(Duration::ZERO);
622 if remaining.is_zero() {
623 return Err(HttpError::Timeout(self.total_timeout));
624 }
625 let sleep_for = delay.min(remaining);
626 match &self.cancel {
627 Some(token) => {
628 tokio::select! {
629 () = tokio::time::sleep(sleep_for) => {}
630 () = token.cancelled() => return Err(HttpError::Cancelled),
631 }
632 }
633 None => tokio::time::sleep(sleep_for).await,
634 }
635 Ok(())
636 }
637}
638
639#[derive(Debug, Clone)]
641pub struct BoundedResponse {
642 pub status: StatusCode,
644 pub headers: HeaderMap,
646 pub body: String,
648}
649
650impl BoundedResponse {
651 pub fn is_success(&self) -> bool {
653 self.status.is_success()
654 }
655
656 pub fn error_for_status(self) -> Result<Self, HttpError> {
658 if self.is_success() {
659 Ok(self)
660 } else {
661 Err(HttpError::Status {
662 status: self.status.as_u16(),
663 body: truncate_for_error(&self.body),
664 })
665 }
666 }
667}
668
669pub type SseStream = Pin<Box<dyn Stream<Item = io::Result<bytes::Bytes>> + Send>>;
671
672pub fn is_retryable_status(status: StatusCode) -> bool {
675 matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
676}
677
678fn effective_retry_mode(
684 method: &Method,
685 opts: &RequestOptions,
686 client_mode: TransportRetryMode,
687) -> TransportRetryMode {
688 match opts.retry_mode {
689 Some(mode) => mode,
690 None if method.is_safe() => client_mode,
691 None => TransportRetryMode::PreDispatchOnly,
692 }
693}
694
695fn is_retryable_transport(err: &reqwest::Error, mode: TransportRetryMode) -> bool {
696 if err.is_connect() {
697 return true;
698 }
699 matches!(mode, TransportRetryMode::AllTransportErrors) && (err.is_timeout() || err.is_request())
700}
701
702pub fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
705 let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
706 if let Ok(seconds) = value.parse::<u64>() {
707 return Some(Duration::from_secs(seconds));
708 }
709 let date = httpdate::parse_http_date(value).ok()?;
710 Some(
711 date.duration_since(SystemTime::now())
712 .unwrap_or(Duration::ZERO),
713 )
714}
715
716pub fn compute_backoff(attempt: usize, policy: RetryPolicy, entropy_nanos: u64) -> Duration {
718 let shift = (attempt as u32).min(31);
720 let multiplier = 1u32.checked_shl(shift).unwrap_or(u32::MAX);
721 let uncapped = policy
722 .base_delay
723 .checked_mul(multiplier)
724 .unwrap_or(policy.max_delay);
725 let cap = uncapped.min(policy.max_delay);
726 let span = cap.as_nanos();
727 if span == 0 {
728 return Duration::ZERO;
729 }
730 let jitter = entropy_nanos % (span as u64);
732 Duration::from_nanos(jitter)
733}
734
735fn random_entropy() -> u64 {
736 SystemTime::now()
742 .duration_since(UNIX_EPOCH)
743 .map(|d| d.as_secs() ^ d.subsec_nanos() as u64)
744 .unwrap_or(0)
745}
746
747fn truncate_for_error(body: &str) -> String {
748 const MAX_ERROR_CHARS: usize = 2_000;
749 if body.chars().count() <= MAX_ERROR_CHARS {
750 body.to_string()
751 } else {
752 let truncated: String = body.chars().take(MAX_ERROR_CHARS).collect();
753 format!("{truncated}…(truncated)")
754 }
755}
756
757pub fn normalize_base_url(raw: &str) -> Result<String, HttpError> {
762 let trimmed = raw.trim().trim_end_matches('/');
763 let parsed = url::Url::parse(trimmed).map_err(|e| HttpError::InvalidUrl {
764 url: raw.to_string(),
765 reason: e.to_string(),
766 })?;
767 if !matches!(parsed.scheme(), "http" | "https") {
768 return Err(HttpError::InvalidUrl {
769 url: raw.to_string(),
770 reason: format!("unsupported scheme {}", parsed.scheme()),
771 });
772 }
773 if parsed.host_str().is_none_or(str::is_empty) {
774 return Err(HttpError::InvalidUrl {
775 url: raw.to_string(),
776 reason: "missing host".to_string(),
777 });
778 }
779 Ok(trimmed.to_string())
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use std::io::{Read, Write};
786 use std::net::TcpListener;
787 use std::sync::atomic::AtomicUsize;
788 use std::sync::Arc;
789
790 fn fast_retry() -> RetryPolicy {
791 RetryPolicy {
792 max_attempts: 3,
793 base_delay: Duration::from_millis(1),
794 max_delay: Duration::from_millis(5),
795 ..Default::default()
796 }
797 }
798
799 #[test]
802 fn retryable_status_set() {
803 for code in [408u16, 429, 500, 502, 503, 504] {
804 assert!(is_retryable_status(StatusCode::from_u16(code).unwrap()));
805 }
806 for code in [400, 401, 403, 404, 409, 422, 501, 505] {
807 assert!(!is_retryable_status(StatusCode::from_u16(code).unwrap()));
808 }
809 }
810
811 #[test]
812 fn retry_after_delta_seconds() {
813 let mut headers = HeaderMap::new();
814 headers.insert(RETRY_AFTER, HeaderValue::from_static("42"));
815 assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(42)));
816 }
817
818 #[test]
819 fn retry_after_http_date() {
820 let mut headers = HeaderMap::new();
821 headers.insert(
823 RETRY_AFTER,
824 HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
825 );
826 assert_eq!(parse_retry_after(&headers), Some(Duration::ZERO));
827 }
828
829 #[test]
830 fn retry_after_future_http_date_is_some() {
831 let mut headers = HeaderMap::new();
832 let future = SystemTime::now() + Duration::from_secs(3600);
834 headers.insert(
835 RETRY_AFTER,
836 HeaderValue::from_str(&httpdate::fmt_http_date(future)).unwrap(),
837 );
838 let parsed = parse_retry_after(&headers).unwrap();
839 assert!(parsed >= Duration::from_secs(3500) && parsed <= Duration::from_secs(3600));
840 }
841
842 #[test]
843 fn backoff_always_bounded_by_cap() {
844 let policy = fast_retry();
845 for attempt in 0..10u32 {
846 for entropy in [0u64, 1, 7, u64::MAX] {
847 let d = compute_backoff(attempt as usize, policy, entropy);
848 assert!(d <= policy.max_delay, "attempt {attempt}");
849 }
850 }
851 }
852
853 #[test]
854 fn normalizes_base_urls() {
855 assert_eq!(
856 normalize_base_url("https://api.example.com/v1/").unwrap(),
857 "https://api.example.com/v1"
858 );
859 assert_eq!(
860 normalize_base_url(" http://localhost:8080 ").unwrap(),
861 "http://localhost:8080"
862 );
863 assert!(normalize_base_url("ftp://api.example.com").is_err());
864 assert!(normalize_base_url("not a url").is_err());
865 assert!(normalize_base_url("https://").is_err());
866 }
867
868 #[derive(Debug, Clone)]
877 struct StubResponse {
878 status: u16,
879 reason: &'static str,
880 extra_headers: &'static str,
881 body: Vec<u8>,
882 }
883
884 fn spawn_stub(script: Vec<StubResponse>) -> (String, Arc<AtomicUsize>) {
885 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
886 let addr = listener.local_addr().unwrap();
887 let count = Arc::new(AtomicUsize::new(0));
888 let count_task = count.clone();
889 std::thread::spawn(move || {
890 for mut stream in listener.incoming().flatten() {
891 let idx = count_task.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
892 let mut buf = [0u8; 4096];
895 let _ = stream.read(&mut buf);
896 let resp = script
897 .get(idx)
898 .unwrap_or_else(|| script.last().expect("empty stub script"))
899 .clone();
900 let head = format!(
901 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n",
902 resp.status,
903 resp.reason,
904 resp.body.len(),
905 resp.extra_headers
906 );
907 let _ = stream.write_all(head.as_bytes());
908 let _ = stream.write_all(&resp.body);
909 let _ = stream.flush();
910 }
911 });
912 (format!("http://127.0.0.1:{}", addr.port()), count)
913 }
914
915 fn body_ok(text: &str) -> StubResponse {
916 StubResponse {
917 status: 200,
918 reason: "OK",
919 extra_headers: "Content-Type: application/json\r\n",
920 body: text.as_bytes().to_vec(),
921 }
922 }
923
924 fn status_response(status: u16, reason: &'static str) -> StubResponse {
925 StubResponse {
926 status,
927 reason,
928 extra_headers: "",
929 body: b"{}".to_vec(),
930 }
931 }
932
933 #[tokio::test]
936 async fn success_is_not_retried() {
937 let (url, count) = spawn_stub(vec![body_ok("{\"ok\":true}")]);
938 let client = HttpClient::api()
939 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
940 .retry(fast_retry())
941 .build()
942 .unwrap();
943 let resp = client.get(&url).await.unwrap();
944 assert!(resp.is_success());
945 assert_eq!(resp.body, "{\"ok\":true}");
946 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
947 }
948
949 #[tokio::test]
950 async fn retries_503_twice_then_succeeds() {
951 let script = vec![
952 status_response(503, "Service Unavailable"),
953 status_response(503, "Service Unavailable"),
954 body_ok("{}"),
955 ];
956 let (url, count) = spawn_stub(script);
957 let client = HttpClient::api()
958 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
959 .retry(fast_retry())
960 .build()
961 .unwrap();
962 let resp = client.get(&url).await.unwrap();
963 assert_eq!(resp.status, 200);
964 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 3);
965 }
966
967 #[tokio::test]
968 async fn status_501_returns_immediately() {
969 let (url, count) = spawn_stub(vec![status_response(501, "Not Implemented")]);
970 let client = HttpClient::api().retry(fast_retry()).build().unwrap();
971 let err = client.get(&url).await.expect_err("501 is an error");
974 assert!(
975 matches!(err, HttpError::Status { status: 501, .. }),
976 "expected HttpError::Status 501, got {err:?}"
977 );
978 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
979 }
980
981 #[tokio::test]
982 async fn status_505_returns_immediately() {
983 let (url, count) = spawn_stub(vec![status_response(505, "HTTP Version Not Supported")]);
984 let client = HttpClient::api().retry(fast_retry()).build().unwrap();
985 let err = client.get(&url).await.expect_err("505 is an error");
986 assert!(
987 matches!(err, HttpError::Status { status: 505, .. }),
988 "expected HttpError::Status 505, got {err:?}"
989 );
990 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
991 }
992
993 #[tokio::test]
994 async fn retry_after_zero_does_not_delay() {
995 let script = vec![
996 StubResponse {
997 status: 429,
998 reason: "Too Many Requests",
999 extra_headers: "Retry-After: 0\r\n",
1000 body: b"{}".to_vec(),
1001 },
1002 body_ok("{}"),
1003 ];
1004 let (url, count) = spawn_stub(script);
1005 let client = HttpClient::api()
1006 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1007 .retry(RetryPolicy {
1010 max_attempts: 3,
1011 base_delay: Duration::from_secs(1),
1012 max_delay: Duration::from_secs(2),
1013 ..Default::default()
1014 })
1015 .build()
1016 .unwrap();
1017 let start = std::time::Instant::now();
1018 let resp = client.get(&url).await.unwrap();
1019 assert_eq!(resp.status, 200);
1020 assert!(start.elapsed() < Duration::from_secs(1));
1021 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1022 }
1023
1024 #[tokio::test]
1025 async fn retry_after_past_http_date_does_not_delay() {
1026 let script = vec![
1027 StubResponse {
1028 status: 503,
1029 reason: "Service Unavailable",
1030 extra_headers: "Retry-After: Wed, 21 Oct 2015 07:28:00 GMT\r\n",
1031 body: b"{}".to_vec(),
1032 },
1033 body_ok("{}"),
1034 ];
1035 let (url, _count) = spawn_stub(script);
1036 let client = HttpClient::api()
1037 .retry(RetryPolicy {
1038 max_attempts: 3,
1039 base_delay: Duration::from_millis(1),
1040 max_delay: Duration::from_millis(5),
1041 ..Default::default()
1042 })
1043 .build()
1044 .unwrap();
1045 let start = std::time::Instant::now();
1046 let resp = client.get(&url).await.unwrap();
1047 assert_eq!(resp.status, 200);
1048 assert!(start.elapsed() < Duration::from_secs(1));
1049 }
1050
1051 #[tokio::test]
1052 async fn body_over_limit_errors() {
1053 let big = vec![b'x'; 200_000];
1054 let (url, _count) = spawn_stub(vec![StubResponse {
1055 status: 200,
1056 reason: "OK",
1057 extra_headers: "",
1058 body: big,
1059 }]);
1060 let client = HttpClient::api()
1061 .retry(RetryPolicy::none())
1062 .max_bytes(1024)
1063 .build()
1064 .unwrap();
1065 let err = client.get(&url).await.unwrap_err();
1066 assert!(
1067 matches!(err, HttpError::BodyTooLarge { limit: 1024 }),
1068 "got {err:?}"
1069 );
1070 }
1071
1072 #[tokio::test]
1073 async fn total_timeout_fires_against_blackhole() {
1074 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1076 let addr = listener.local_addr().unwrap();
1077 std::thread::spawn(move || {
1078 for stream in listener.incoming().flatten() {
1079 let _owned = stream;
1082 std::thread::sleep(Duration::from_secs(10));
1083 }
1084 });
1085 let url = format!("http://127.0.0.1:{}", addr.port());
1086 let client = HttpClient::api()
1087 .timeouts(Duration::from_secs(5), Duration::from_millis(200))
1088 .retry(fast_retry())
1089 .build()
1090 .unwrap();
1091 let err = client.get(&url).await.unwrap_err();
1092 assert!(matches!(err, HttpError::Timeout(_)), "got {err:?}");
1093 }
1094
1095 #[tokio::test]
1096 async fn pre_cancelled_token_aborts_before_request() {
1097 let (url, count) = spawn_stub(vec![body_ok("{}")]);
1098 let token = CancellationToken::new();
1099 token.cancel();
1100 let client = HttpClient::api()
1101 .retry(fast_retry())
1102 .cancellation_token(token)
1103 .build()
1104 .unwrap();
1105 let err = client.get(&url).await.unwrap_err();
1106 assert!(matches!(err, HttpError::Cancelled));
1107 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
1108 }
1109
1110 #[tokio::test]
1111 async fn cancel_during_retry_wait_aborts() {
1112 let script = vec![
1113 StubResponse {
1114 status: 503,
1115 reason: "Service Unavailable",
1116 extra_headers: "Retry-After: 30\r\n",
1117 body: b"{}".to_vec(),
1118 },
1119 body_ok("{}"),
1120 ];
1121 let (url, count) = spawn_stub(script);
1122 let token = CancellationToken::new();
1123 let canceller = token.clone();
1124 std::thread::spawn(move || {
1125 std::thread::sleep(Duration::from_millis(50));
1126 canceller.cancel();
1127 });
1128 let client = HttpClient::api()
1129 .timeouts(Duration::from_secs(5), Duration::from_secs(30))
1130 .retry(RetryPolicy {
1131 max_attempts: 3,
1132 base_delay: Duration::from_millis(1),
1133 max_delay: Duration::from_millis(5),
1134 ..Default::default()
1135 })
1136 .cancellation_token(token)
1137 .build()
1138 .unwrap();
1139 let start = std::time::Instant::now();
1140 let err = client.get(&url).await.unwrap_err();
1141 assert!(matches!(err, HttpError::Cancelled), "got {err:?}");
1142 assert!(start.elapsed() < Duration::from_secs(2));
1143 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
1144 }
1145
1146 #[tokio::test]
1147 async fn sse_retries_until_stream_opens() {
1148 let script = vec![
1149 status_response(503, "Service Unavailable"),
1150 StubResponse {
1151 status: 200,
1152 reason: "OK",
1153 extra_headers: "Content-Type: text/event-stream\r\n",
1154 body: b"data: one\n\ndata: two\n\n".to_vec(),
1155 },
1156 ];
1157 let (url, count) = spawn_stub(script);
1158 let client = HttpClient::sse()
1159 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1160 .retry(fast_retry())
1161 .build()
1162 .unwrap();
1163 let mut stream = client
1164 .open_sse(&url, None, RequestOptions::new())
1165 .await
1166 .unwrap();
1167 let mut collected = Vec::new();
1168 while let Some(chunk) = stream.next().await {
1169 collected.extend_from_slice(&chunk.unwrap());
1170 }
1171 assert_eq!(collected, b"data: one\n\ndata: two\n\n");
1172 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1173 }
1174
1175 #[tokio::test]
1176 async fn sse_mid_stream_close_does_not_reconnect() {
1177 let script = vec![StubResponse {
1178 status: 200,
1179 reason: "OK",
1180 extra_headers: "Content-Type: text/event-stream\r\n",
1181 body: b"data: only-one-event\n\n".to_vec(),
1182 }];
1183 let (url, count) = spawn_stub(script);
1184 let client = HttpClient::sse().retry(fast_retry()).build().unwrap();
1185 let mut stream = client
1186 .open_sse(&url, None, RequestOptions::new())
1187 .await
1188 .unwrap();
1189 let mut collected = Vec::new();
1190 while let Some(chunk) = stream.next().await {
1191 collected.extend_from_slice(&chunk.unwrap());
1192 }
1193 assert_eq!(collected, b"data: only-one-event\n\n");
1194 tokio::time::sleep(Duration::from_millis(50)).await;
1196 assert_eq!(
1197 count.load(std::sync::atomic::Ordering::SeqCst),
1198 1,
1199 "mid-stream close must not trigger a reconnect"
1200 );
1201 }
1202
1203 #[tokio::test]
1204 async fn sse_error_status_is_reported_with_body() {
1205 let (url, _count) = spawn_stub(vec![StubResponse {
1206 status: 400,
1207 reason: "Bad Request",
1208 extra_headers: "",
1209 body: b"{\"error\":\"bad payload\"}".to_vec(),
1210 }]);
1211 let client = HttpClient::sse()
1212 .retry(RetryPolicy::none())
1213 .build()
1214 .unwrap();
1215 let Err(HttpError::Status { status, body }) =
1218 client.open_sse(&url, None, RequestOptions::new()).await
1219 else {
1220 panic!("expected an HttpError::Status");
1221 };
1222 assert_eq!(status, 400);
1223 assert_eq!(body, "{\"error\":\"bad payload\"}");
1224 }
1225
1226 #[tokio::test]
1227 async fn per_request_bearer_header_is_sent() {
1228 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1231 let addr = listener.local_addr().unwrap();
1232 std::thread::spawn(move || {
1233 for mut stream in listener.incoming().flatten() {
1234 let mut buf = Vec::new();
1235 let mut tmp = [0u8; 4096];
1236 while let Ok(n) = stream.read(&mut tmp) {
1237 if n == 0 {
1238 break;
1239 }
1240 buf.extend_from_slice(&tmp[..n]);
1241 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
1242 break;
1243 }
1244 }
1245 let echo = String::from_utf8_lossy(&buf).to_string().to_lowercase();
1247 let answer = format!(
1248 "{{\"seen\":\"{}\"}}",
1249 echo.contains("authorization: bearer secret")
1250 );
1251 let head = format!(
1252 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1253 answer.len()
1254 );
1255 let _ = stream.write_all(head.as_bytes());
1256 let _ = stream.write_all(answer.as_bytes());
1257 }
1258 });
1259 let url = format!("http://127.0.0.1:{}", addr.port());
1260 let client = HttpClient::api()
1261 .retry(RetryPolicy::none())
1262 .build()
1263 .unwrap();
1264 let resp = client
1265 .post_json_with(
1266 &url,
1267 &serde_json::json!({"a": 1}),
1268 RequestOptions::new().bearer("secret"),
1269 )
1270 .await
1271 .unwrap();
1272 assert_eq!(resp.body, "{\"seen\":\"true\"}");
1273 }
1274
1275 #[test]
1278 fn effective_retry_mode_is_method_aware() {
1279 let client_policy = TransportRetryMode::AllTransportErrors;
1280 assert_eq!(
1282 effective_retry_mode(&Method::GET, &RequestOptions::new(), client_policy),
1283 TransportRetryMode::AllTransportErrors
1284 );
1285 assert_eq!(
1286 effective_retry_mode(&Method::HEAD, &RequestOptions::new(), client_policy),
1287 TransportRetryMode::AllTransportErrors
1288 );
1289 for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
1292 assert_eq!(
1293 effective_retry_mode(&method, &RequestOptions::new(), client_policy),
1294 TransportRetryMode::PreDispatchOnly,
1295 "{method} must default to PreDispatchOnly"
1296 );
1297 }
1298 assert_eq!(
1300 effective_retry_mode(
1301 &Method::POST,
1302 &RequestOptions::new().retry_mode(TransportRetryMode::AllTransportErrors),
1303 TransportRetryMode::PreDispatchOnly,
1304 ),
1305 TransportRetryMode::AllTransportErrors
1306 );
1307 assert_eq!(
1308 effective_retry_mode(
1309 &Method::GET,
1310 &RequestOptions::new().retry_mode(TransportRetryMode::PreDispatchOnly),
1311 TransportRetryMode::AllTransportErrors,
1312 ),
1313 TransportRetryMode::PreDispatchOnly
1314 );
1315 }
1316
1317 fn spawn_close_after_dispatch_stub() -> (String, Arc<AtomicUsize>) {
1322 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1323 let addr = listener.local_addr().unwrap();
1324 let count = Arc::new(AtomicUsize::new(0));
1325 let count_task = count.clone();
1326 std::thread::spawn(move || {
1327 for mut stream in listener.incoming().flatten() {
1328 count_task.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1329 let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
1335 let mut buf = [0u8; 4096];
1336 loop {
1337 match stream.read(&mut buf) {
1338 Ok(0) => break,
1339 Ok(_) => {}
1340 Err(_) => break,
1341 }
1342 }
1343 let _ = stream.shutdown(std::net::Shutdown::Both);
1345 }
1346 });
1347 (format!("http://127.0.0.1:{}", addr.port()), count)
1348 }
1349
1350 #[tokio::test]
1351 async fn post_transport_error_after_dispatch_is_not_retried_by_default() {
1352 let (url, count) = spawn_close_after_dispatch_stub();
1355 let client = HttpClient::api()
1356 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1357 .retry(fast_retry())
1358 .build()
1359 .unwrap();
1360 let err = client
1361 .post_json(&url, &serde_json::json!({"id": 1}))
1362 .await
1363 .unwrap_err();
1364 assert!(matches!(err, HttpError::Transport(_)), "got {err:?}");
1365 assert_eq!(
1366 count.load(std::sync::atomic::Ordering::SeqCst),
1367 1,
1368 "dispatched POST must not be retried under PreDispatchOnly"
1369 );
1370 }
1371
1372 #[tokio::test]
1373 async fn post_retries_after_dispatch_only_with_explicit_opt_in() {
1374 let (url, count) = spawn_close_after_dispatch_stub();
1377 let client = HttpClient::api()
1378 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1379 .retry(fast_retry())
1380 .build()
1381 .unwrap();
1382 let result = client
1383 .post_json_with(
1384 &url,
1385 &serde_json::json!({"id": 1}),
1386 RequestOptions::new().retry_mode(TransportRetryMode::AllTransportErrors),
1387 )
1388 .await;
1389 assert!(result.is_err());
1390 assert_eq!(
1391 count.load(std::sync::atomic::Ordering::SeqCst),
1392 3,
1393 "explicit AllTransportErrors must retry the dispatched POST"
1394 );
1395 }
1396
1397 #[tokio::test]
1398 async fn get_still_retries_after_dispatch_under_default_policy() {
1399 let (url, count) = spawn_close_after_dispatch_stub();
1401 let client = HttpClient::api()
1402 .timeouts(Duration::from_secs(5), Duration::from_secs(10))
1403 .retry(fast_retry())
1404 .build()
1405 .unwrap();
1406 assert!(client.get(&url).await.is_err());
1407 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 3);
1408 }
1409
1410 #[tokio::test]
1411 async fn buffered_body_read_respects_total_timeout() {
1412 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1416 let addr = listener.local_addr().unwrap();
1417 std::thread::spawn(move || {
1418 for mut stream in listener.incoming().flatten() {
1419 let mut buf = [0u8; 4096];
1420 let _ = stream.read(&mut buf);
1421 let head = "HTTP/1.1 200 OK
1422Content-Length: 64
1423Connection: close
1424
1425";
1426 let _ = stream.write_all(head.as_bytes());
1427 let _ = stream.write_all(b"hello");
1429 let _ = stream.flush();
1430 std::thread::sleep(Duration::from_secs(5));
1432 }
1433 });
1434 let url = format!("http://127.0.0.1:{}", addr.port());
1435 let client = HttpClient::api()
1436 .timeouts(Duration::from_secs(5), Duration::from_millis(300))
1437 .retry(RetryPolicy::none())
1438 .build()
1439 .unwrap();
1440 let start = std::time::Instant::now();
1441 let err = client.get(&url).await.unwrap_err();
1442 assert!(matches!(err, HttpError::Timeout(_)), "got {err:?}");
1443 assert!(
1444 start.elapsed() < Duration::from_secs(2),
1445 "body read must be bounded by the total deadline"
1446 );
1447 }
1448}