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