1#![allow(
17 clippy::disallowed_methods,
18 reason = "This module implements the shared timeout policy; bare builders are the policy's own building blocks."
19)]
20
21use reqwest::{Client, ClientBuilder};
22use std::future::Future;
23use std::sync::LazyLock;
24use std::time::Duration;
25
26use crate::retry::RetryPolicy;
27
28const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
30const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
32const DEFAULT_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct HttpTimeouts {
43 pub connect: Option<Duration>,
45 pub request: Option<Duration>,
47 pub download: Option<Duration>,
49}
50
51impl Default for HttpTimeouts {
52 fn default() -> Self {
53 Self {
54 connect: Some(DEFAULT_CONNECT_TIMEOUT),
55 request: Some(DEFAULT_REQUEST_TIMEOUT),
56 download: Some(DEFAULT_DOWNLOAD_TIMEOUT),
57 }
58 }
59}
60
61impl HttpTimeouts {
62 #[must_use]
65 pub const fn from_seconds(connect: u64, request: u64, download: u64) -> Self {
66 Self {
67 connect: duration_or_disabled(connect),
68 request: duration_or_disabled(request),
69 download: duration_or_disabled(download),
70 }
71 }
72
73 fn apply_to(self, mut builder: ClientBuilder) -> ClientBuilder {
76 if let Some(request) = self.request {
77 builder = builder.timeout(request);
78 }
79 if let Some(connect) = self.connect {
80 builder = builder.connect_timeout(connect);
81 }
82 builder
83 }
84}
85
86const fn duration_or_disabled(seconds: u64) -> Option<Duration> {
89 if seconds == 0 {
90 None
91 } else {
92 Some(Duration::from_secs(seconds))
93 }
94}
95
96static HTTP_TIMEOUTS: LazyLock<arc_swap::ArcSwap<HttpTimeouts>> =
97 LazyLock::new(|| arc_swap::ArcSwap::from_pointee(HttpTimeouts::default()));
98
99pub fn install_http_timeouts(timeouts: HttpTimeouts) {
105 HTTP_TIMEOUTS.store(std::sync::Arc::new(timeouts));
106}
107
108#[must_use]
110pub fn http_timeouts() -> HttpTimeouts {
111 **HTTP_TIMEOUTS.load()
112}
113
114fn apply_platform_proxy_policy(builder: ClientBuilder) -> ClientBuilder {
115 #[cfg(target_os = "macos")]
116 {
117 builder.no_proxy()
119 }
120 #[cfg(not(target_os = "macos"))]
121 {
122 builder
123 }
124}
125
126fn try_build_client<F>(configure: F) -> Result<Client, reqwest::Error>
131where
132 F: Fn(ClientBuilder) -> ClientBuilder,
133{
134 let primary_builder = configure(apply_platform_proxy_policy(ClientBuilder::new()));
135 match primary_builder.build() {
136 Ok(client) => Ok(client),
137 Err(primary_err) => {
138 let fallback_builder = HttpTimeouts::default().apply_to(apply_platform_proxy_policy(ClientBuilder::new()));
139 match fallback_builder.build() {
140 Ok(client) => Ok(client),
141 Err(fallback_err) => {
142 tracing::error!(
143 primary_error = %primary_err,
144 fallback_error = %fallback_err,
145 "HTTP client creation failed with both primary and fallback configurations"
146 );
147 Err(fallback_err)
148 }
149 }
150 }
151 }
152}
153
154fn build_client<F>(configure: F) -> Client
162where
163 F: Fn(ClientBuilder) -> ClientBuilder,
164{
165 try_build_client(configure).unwrap_or_else(|e| {
166 tracing::error!(
167 error = %e,
168 "HTTP client build failed (primary + fallback); falling back to bare default client. \
169 This usually indicates a TLS configuration issue or system resource exhaustion."
170 );
171 Client::new()
172 })
173}
174
175pub fn create_default_client() -> Client {
177 build_client(|builder| http_timeouts().apply_to(builder))
178}
179
180pub fn create_client_with_timeout(timeout: Duration) -> Client {
185 let connect = http_timeouts().connect;
186 build_client(move |builder| {
187 let builder = builder.timeout(timeout);
188 match connect {
189 Some(connect) => builder.connect_timeout(connect),
190 None => builder,
191 }
192 })
193}
194
195pub fn create_client_with_timeouts(connect_timeout: Duration, request_timeout: Duration) -> Client {
197 build_client(|builder| builder.timeout(request_timeout).connect_timeout(connect_timeout))
198}
199
200pub fn create_client_without_redirects(
202 connect_timeout: Duration,
203 request_timeout: Duration,
204) -> Result<Client, reqwest::Error> {
205 apply_platform_proxy_policy(ClientBuilder::new())
206 .timeout(request_timeout)
207 .connect_timeout(connect_timeout)
208 .redirect(reqwest::redirect::Policy::none())
209 .build()
210}
211
212pub fn create_default_client_without_redirects() -> Result<Client, reqwest::Error> {
217 let timeouts = http_timeouts();
218 let mut builder = apply_platform_proxy_policy(ClientBuilder::new()).redirect(reqwest::redirect::Policy::none());
219 if let Some(request) = timeouts.request {
220 builder = builder.timeout(request);
221 }
222 if let Some(connect) = timeouts.connect {
223 builder = builder.connect_timeout(connect);
224 }
225 builder.build()
226}
227
228pub fn create_client_with_user_agent(user_agent: &str) -> Client {
230 let timeouts = http_timeouts();
231 build_client(move |mut builder| {
232 builder = builder.user_agent(user_agent);
233 timeouts.apply_to(builder)
234 })
235}
236
237pub fn create_streaming_client() -> Client {
244 let connect = http_timeouts().connect;
245 build_client(move |builder| {
246 let builder = builder.tcp_keepalive(Some(Duration::from_secs(60)));
247 match connect {
248 Some(connect) => builder.connect_timeout(connect),
249 None => builder,
250 }
251 })
252}
253
254pub fn create_download_client(user_agent: &str) -> Client {
261 let timeouts = http_timeouts();
262 build_client(move |mut builder| {
263 builder = builder.user_agent(user_agent).tcp_keepalive(Some(Duration::from_secs(60)));
264 if let Some(download) = timeouts.download {
265 builder = builder.timeout(download);
266 }
267 if let Some(connect) = timeouts.connect {
268 builder = builder.connect_timeout(connect);
269 }
270 builder
271 })
272}
273
274pub fn get_or_create_default_client(existing: Option<Client>) -> Client {
276 existing.unwrap_or_else(create_default_client)
277}
278
279pub fn apply_connect_timeout(builder: ClientBuilder) -> ClientBuilder {
285 match http_timeouts().connect {
286 Some(connect) => builder.connect_timeout(connect),
287 None => builder,
288 }
289}
290
291pub async fn send_with_retry<F, Fut>(policy: &RetryPolicy, make_request: F) -> Result<reqwest::Response, reqwest::Error>
312where
313 F: Fn() -> Fut,
314 Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
315{
316 send_with_retry_inner(policy, true, make_request).await
317}
318
319pub async fn send_with_retry_transport_only<F, Fut>(
325 policy: &RetryPolicy,
326 make_request: F,
327) -> Result<reqwest::Response, reqwest::Error>
328where
329 F: Fn() -> Fut,
330 Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
331{
332 send_with_retry_inner(policy, false, make_request).await
333}
334
335async fn send_with_retry_inner<F, Fut>(
336 policy: &RetryPolicy,
337 retry_status: bool,
338 make_request: F,
339) -> Result<reqwest::Response, reqwest::Error>
340where
341 F: Fn() -> Fut,
342 Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
343{
344 let attempts = policy.max_attempts.max(1);
347 let mut attempt: u32 = 0;
348 loop {
349 if attempt > 0 {
350 tokio::time::sleep(policy.delay_for_attempt(attempt - 1)).await;
351 }
352 let last = attempt + 1 >= attempts;
353 match make_request().await {
354 Ok(response) if !retry_status || last || !policy.classify_status(response.status().as_u16()).retryable => {
355 return Ok(response);
356 }
357 Ok(_) => {}
358 Err(error) if error.is_builder() || last => return Err(error),
359 Err(_) => {}
360 }
361 attempt += 1;
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn default_timeouts_match_historical_constants() {
371 let defaults = HttpTimeouts::default();
372 assert_eq!(defaults.connect, Some(Duration::from_secs(5)));
373 assert_eq!(defaults.request, Some(Duration::from_secs(30)));
374 assert_eq!(defaults.download, Some(Duration::from_secs(300)));
375 }
376
377 #[test]
378 fn zero_seconds_disables_the_phase() {
379 let timeouts = HttpTimeouts::from_seconds(0, 0, 0);
380 assert_eq!(timeouts.connect, None);
381 assert_eq!(timeouts.request, None);
382 assert_eq!(timeouts.download, None);
383 }
384
385 #[test]
386 fn from_seconds_preserves_explicit_values() {
387 let timeouts = HttpTimeouts::from_seconds(12, 45, 900);
388 assert_eq!(timeouts.connect, Some(Duration::from_secs(12)));
389 assert_eq!(timeouts.request, Some(Duration::from_secs(45)));
390 assert_eq!(timeouts.download, Some(Duration::from_secs(900)));
391 }
392
393 struct InstalledTimeoutsGuard(HttpTimeouts);
396
397 impl InstalledTimeoutsGuard {
398 fn recording_current() -> Self {
399 Self(http_timeouts())
400 }
401 }
402
403 impl Drop for InstalledTimeoutsGuard {
404 fn drop(&mut self) {
405 install_http_timeouts(self.0);
406 }
407 }
408
409 #[test]
412 #[serial_test::serial]
413 fn installing_timeouts_is_observable_and_restorable() {
414 let _guard = InstalledTimeoutsGuard::recording_current();
415
416 install_http_timeouts(HttpTimeouts::from_seconds(0, 7, 11));
417 assert_eq!(http_timeouts().connect, None);
418 assert_eq!(http_timeouts().request, Some(Duration::from_secs(7)));
419 assert_eq!(http_timeouts().download, Some(Duration::from_secs(11)));
420 }
421
422 #[test]
423 #[serial_test::serial]
424 fn clients_build_for_every_timeout_shape() {
425 let _guard = InstalledTimeoutsGuard::recording_current();
426
427 for timeouts in [
429 HttpTimeouts::default(),
430 HttpTimeouts::from_seconds(0, 0, 0),
431 HttpTimeouts::from_seconds(1, 1, 1),
432 ] {
433 install_http_timeouts(timeouts);
434 drop(create_default_client());
435 drop(create_client_with_timeout(Duration::from_secs(3)));
436 drop(create_streaming_client());
437 drop(create_client_with_user_agent("vtcode-test"));
438 drop(create_download_client("vtcode-test"));
439 let origin_bound =
440 create_default_client_without_redirects().expect("client without redirects should build");
441 drop(origin_bound);
442 }
443 }
444
445 const UNROUTABLE_URL: &str = "http://198.51.100.1:9/hang";
448
449 const STALL_TEST_OUTER_GUARD: Duration = Duration::from_secs(15);
453
454 fn bind_fixture_listener() -> Option<std::net::TcpListener> {
458 match std::net::TcpListener::bind("127.0.0.1:0") {
459 Ok(listener) => Some(listener),
460 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
461 Err(err) => panic!("bind fixture listener: {err}"),
462 }
463 }
464
465 fn stalled_listener() -> Option<std::net::TcpListener> {
470 bind_fixture_listener()
471 }
472
473 fn respond_once(listener: std::net::TcpListener) {
477 drop(std::thread::spawn(move || {
478 let (mut stream, _) = listener.accept().expect("accept test connection");
479 use std::io::{Read, Write};
480 let mut request = [0u8; 4096];
481 let request_bytes = stream.read(&mut request).expect("read test request");
482 assert!(request_bytes > 0, "client must send the request");
483 stream
484 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
485 .expect("write test response");
486 }));
487 }
488
489 #[tokio::test]
490 #[serial_test::serial]
491 async fn default_client_request_budget_bounds_an_accept_then_stall_server() {
492 let _guard = InstalledTimeoutsGuard::recording_current();
493 install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
494
495 let Some(listener) = stalled_listener() else { return };
496 let addr = listener.local_addr().expect("listener address");
497
498 let client = create_default_client();
499 let started = std::time::Instant::now();
500 let outcome =
501 tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
502 let elapsed = started.elapsed();
503
504 let error = outcome
505 .expect("request budget must fire before the outer guard")
506 .expect_err("a stalled server must not succeed");
507 assert!(error.is_timeout(), "expected a timeout error, got: {error}");
508 assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
509 }
510
511 #[tokio::test]
512 #[serial_test::serial]
513 async fn default_client_connect_phase_cannot_hang_on_unroutable_host() {
514 let _guard = InstalledTimeoutsGuard::recording_current();
515 install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
516
517 let client = create_default_client();
518 let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
519
520 let error = outcome
524 .expect("connect phase must resolve before the outer guard")
525 .expect_err("an unroutable endpoint must not succeed");
526 assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
527 }
528
529 #[tokio::test]
530 #[serial_test::serial]
531 async fn origin_bound_client_request_budget_bounds_a_stalled_server() {
532 let _guard = InstalledTimeoutsGuard::recording_current();
533 install_http_timeouts(HttpTimeouts::from_seconds(5, 2, 300));
534
535 let Some(listener) = stalled_listener() else { return };
536 let addr = listener.local_addr().expect("listener address");
537
538 let client = create_default_client_without_redirects().expect("origin-bound client builds");
539 let started = std::time::Instant::now();
540 let outcome =
541 tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(format!("http://{addr}/hang")).send()).await;
542 let elapsed = started.elapsed();
543
544 let error = outcome
545 .expect("request budget must fire before the outer guard")
546 .expect_err("a stalled server must not succeed");
547 assert!(error.is_timeout(), "expected a timeout error, got: {error}");
548 assert!(elapsed >= Duration::from_secs(2), "2s budget returned after {elapsed:?}");
549 }
550
551 #[tokio::test]
552 #[serial_test::serial]
553 async fn bounded_clients_still_succeed_against_a_healthy_server() {
554 let _guard = InstalledTimeoutsGuard::recording_current();
555 install_http_timeouts(HttpTimeouts::from_seconds(5, 5, 300));
556
557 let Some(listener) = bind_fixture_listener() else {
558 return;
559 };
560 let addr = listener.local_addr().expect("listener address");
561 respond_once(listener);
562
563 let outcome =
564 tokio::time::timeout(STALL_TEST_OUTER_GUARD, create_default_client().get(format!("http://{addr}/")).send())
565 .await;
566 let body = outcome
567 .expect("healthy server must answer before the outer guard")
568 .expect("bounded client must succeed")
569 .text()
570 .await
571 .expect("read healthy body");
572 assert_eq!(body, "ok");
573 }
574
575 #[tokio::test]
576 #[serial_test::serial]
577 async fn apply_connect_timeout_bounds_an_explicit_builder() {
578 let _guard = InstalledTimeoutsGuard::recording_current();
579 install_http_timeouts(HttpTimeouts::from_seconds(2, 30, 300));
580
581 let client = apply_connect_timeout(Client::builder())
582 .build()
583 .expect("explicit builder builds");
584 let outcome = tokio::time::timeout(STALL_TEST_OUTER_GUARD, client.get(UNROUTABLE_URL).send()).await;
585
586 let error = outcome
589 .expect("connect phase must resolve before the outer guard")
590 .expect_err("an unroutable endpoint must not succeed");
591 assert!(!error.to_string().is_empty(), "failure must carry a diagnostic");
592 }
593
594 fn respond_scripted(listener: std::net::TcpListener, statuses: Vec<u16>) {
597 drop(std::thread::spawn(move || {
598 use std::io::{Read, Write};
599 for status in statuses {
600 let Ok((mut stream, _)) = listener.accept() else {
601 break;
602 };
603 let mut request = [0u8; 4096];
604 if stream.read(&mut request).is_err() {
605 break;
606 }
607 let response = format!("HTTP/1.1 {status} Test\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
608 if stream.write_all(response.as_bytes()).is_err() {
609 break;
610 }
611 }
612 }));
613 }
614
615 fn reserve_refused_port() -> Option<u16> {
618 match std::net::TcpListener::bind("127.0.0.1:0") {
619 Ok(listener) => Some(listener.local_addr().expect("listener address").port()),
620 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => None,
621 Err(err) => panic!("reserve refused port: {err}"),
622 }
623 }
624
625 fn retry_test_policy() -> RetryPolicy {
626 RetryPolicy::from_retries(2, Duration::from_millis(1), Duration::from_millis(5), 2.0)
627 .with_jitter(RetryPolicy::DEFAULT_JITTER)
628 }
629
630 fn retry_test_client() -> Client {
631 create_client_with_timeouts(Duration::from_secs(5), Duration::from_secs(5))
632 }
633
634 #[tokio::test]
635 async fn retryable_status_is_retried_until_success() {
636 let Some(listener) = bind_fixture_listener() else {
637 return;
638 };
639 let addr = listener.local_addr().expect("listener address");
640 respond_scripted(listener, vec![503, 429, 200]);
641
642 let client = retry_test_client();
643 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
644 let policy = retry_test_policy();
645 let outcome = tokio::time::timeout(
646 STALL_TEST_OUTER_GUARD,
647 send_with_retry(&policy, || {
648 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
649 client.get(format!("http://{addr}/")).send()
650 }),
651 )
652 .await
653 .expect("scripted server must answer before the outer guard")
654 .expect("503,429,200 must recover");
655
656 assert_eq!(outcome.status().as_u16(), 200);
657 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
658 }
659
660 #[tokio::test]
661 async fn exhausted_status_budget_returns_the_final_response() {
662 let Some(listener) = bind_fixture_listener() else {
663 return;
664 };
665 let addr = listener.local_addr().expect("listener address");
666 respond_scripted(listener, vec![503, 503, 503, 503]);
667
668 let client = retry_test_client();
669 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
670 let policy = retry_test_policy();
671 let outcome = tokio::time::timeout(
672 STALL_TEST_OUTER_GUARD,
673 send_with_retry(&policy, || {
674 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
675 client.get(format!("http://{addr}/")).send()
676 }),
677 )
678 .await
679 .expect("scripted server must answer before the outer guard")
680 .expect("exhaustion returns the response, not an error");
681
682 assert_eq!(outcome.status().as_u16(), 503);
685 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
686 }
687
688 #[tokio::test]
689 async fn non_retryable_status_returns_immediately() {
690 let Some(listener) = bind_fixture_listener() else {
691 return;
692 };
693 let addr = listener.local_addr().expect("listener address");
694 respond_scripted(listener, vec![404, 200]);
695
696 let client = retry_test_client();
697 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
698 let policy = retry_test_policy();
699 let outcome = tokio::time::timeout(
700 STALL_TEST_OUTER_GUARD,
701 send_with_retry(&policy, || {
702 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
703 client.get(format!("http://{addr}/")).send()
704 }),
705 )
706 .await
707 .expect("scripted server must answer before the outer guard")
708 .expect("404 must arrive as a response");
709
710 assert_eq!(outcome.status().as_u16(), 404);
713 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
714 }
715
716 #[tokio::test]
717 async fn transport_failure_is_retried_then_returned() {
718 let Some(port) = reserve_refused_port() else {
719 return;
720 };
721
722 let client = retry_test_client();
723 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
724 let policy = retry_test_policy();
725 let outcome = tokio::time::timeout(
726 STALL_TEST_OUTER_GUARD,
727 send_with_retry(&policy, || {
728 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
729 client.get(format!("http://127.0.0.1:{port}/")).send()
730 }),
731 )
732 .await
733 .expect("refused connections must resolve before the outer guard");
734
735 assert!(outcome.is_err(), "a refused port must not succeed");
736 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3);
737 }
738
739 #[tokio::test]
740 async fn builder_error_fails_fast_without_retry() {
741 let client = retry_test_client();
742 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
743 let policy = retry_test_policy();
744 let outcome = send_with_retry(&policy, || {
745 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
746 client.get("http://exa mple.invalid/").send()
747 })
748 .await;
749
750 let error = outcome.expect_err("a malformed URL must not succeed");
753 assert!(error.is_builder(), "expected a builder error, got: {error}");
754 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
755 }
756
757 #[tokio::test]
758 async fn transport_only_helper_ignores_retryable_status() {
759 let Some(listener) = bind_fixture_listener() else {
760 return;
761 };
762 let addr = listener.local_addr().expect("listener address");
763 respond_scripted(listener, vec![503, 200]);
764
765 let client = retry_test_client();
766 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
767 let policy = RetryPolicy::from_retries(1, Duration::from_millis(1), Duration::from_millis(5), 2.0)
768 .with_jitter(RetryPolicy::DEFAULT_JITTER);
769 let outcome = tokio::time::timeout(
770 STALL_TEST_OUTER_GUARD,
771 send_with_retry_transport_only(&policy, || {
772 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
773 client.get(format!("http://{addr}/")).send()
774 }),
775 )
776 .await
777 .expect("scripted server must answer before the outer guard")
778 .expect("503 must arrive as a response");
779
780 assert_eq!(outcome.status().as_u16(), 503);
781 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
782 }
783
784 #[tokio::test]
785 async fn transport_only_helper_still_retries_transport_failure() {
786 let Some(port) = reserve_refused_port() else {
787 return;
788 };
789
790 let client = retry_test_client();
791 let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
792 let policy = RetryPolicy::from_retries(1, Duration::from_millis(1), Duration::from_millis(5), 2.0)
793 .with_jitter(RetryPolicy::DEFAULT_JITTER);
794 let outcome = tokio::time::timeout(
795 STALL_TEST_OUTER_GUARD,
796 send_with_retry_transport_only(&policy, || {
797 let _ = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
798 client.get(format!("http://127.0.0.1:{port}/")).send()
799 }),
800 )
801 .await
802 .expect("refused connections must resolve before the outer guard");
803
804 assert!(outcome.is_err(), "a refused port must not succeed");
805 assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
806 }
807}