1use std::collections::HashMap;
49use std::sync::atomic::{AtomicUsize, Ordering};
50use std::sync::{Arc, Mutex};
51use std::time::{Duration, Instant};
52
53use bytes::Bytes;
54use reqwest::Method;
55use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
56use serde::Serialize;
57use serde::de::DeserializeOwned;
58
59#[derive(Debug, thiserror::Error)]
63pub enum ClientError {
64 #[error("outbound HTTP request failed: {0}")]
66 Request(#[from] reqwest::Error),
67 #[error("JSON error: {0}")]
69 Json(#[from] serde_json::Error),
70 #[error("no mock registered for {0} {1}")]
72 NoMock(String, String),
73 #[error("outbound circuit breaker is open")]
75 CircuitBreakerOpen,
76}
77
78pub struct Response {
85 status: reqwest::StatusCode,
86 headers: HeaderMap,
87 body: Bytes,
88 url: Option<reqwest::Url>,
89}
90
91impl Response {
92 pub const fn status(&self) -> reqwest::StatusCode {
94 self.status
95 }
96
97 pub const fn headers(&self) -> &HeaderMap {
99 &self.headers
100 }
101
102 pub fn is_success(&self) -> bool {
104 self.status.is_success()
105 }
106
107 pub const fn url(&self) -> Option<&reqwest::Url> {
109 self.url.as_ref()
110 }
111
112 pub fn json<T: DeserializeOwned>(self) -> Result<T, ClientError> {
117 serde_json::from_slice(&self.body).map_err(ClientError::Json)
118 }
119
120 pub fn text(self) -> String {
122 String::from_utf8_lossy(&self.body).into_owned()
123 }
124
125 pub fn bytes(self) -> Bytes {
127 self.body
128 }
129}
130
131#[derive(Clone, Debug)]
135pub struct RetryPolicy {
136 pub max_retries: u32,
139 pub retry_idempotent_only: bool,
142 pub max_retry_after: Duration,
144 pub request_timeout: Option<Duration>,
146}
147
148impl Default for RetryPolicy {
149 fn default() -> Self {
150 Self {
151 max_retries: 3,
152 retry_idempotent_only: true,
153 max_retry_after: Duration::from_secs(10),
154 request_timeout: Some(Duration::from_secs(30)),
155 }
156 }
157}
158
159pub(crate) struct MockEntry {
163 pub(crate) method: Option<Method>,
164 pub(crate) path: String,
166 pub(crate) alias: Option<String>,
168 pub(crate) status: u16,
169 pub(crate) body: Option<serde_json::Value>,
170 pub(crate) call_count: Arc<AtomicUsize>,
171}
172
173pub(crate) struct MockResponse {
175 pub(crate) status: u16,
176 pub(crate) body: Option<serde_json::Value>,
177}
178
179pub struct MockRegistry {
185 entries: Mutex<Vec<MockEntry>>,
186}
187
188impl MockRegistry {
189 #[must_use]
191 pub const fn new() -> Self {
192 Self {
193 entries: Mutex::new(Vec::new()),
194 }
195 }
196
197 pub(crate) fn register(&self, entry: MockEntry) {
199 self.entries
200 .lock()
201 .expect("mock registry lock poisoned")
202 .push(entry);
203 }
204
205 pub(crate) fn find_match(
208 &self,
209 method: &Method,
210 url: &str,
211 alias: Option<&str>,
212 ) -> Option<MockResponse> {
213 let url_path_owned: String = reqwest::Url::parse(url).map_or_else(
219 |_| {
220 let s = url.split_once('?').map_or(url, |(p, _)| p);
221 s.split_once('#').map_or(s, |(p, _)| p).to_owned()
222 },
223 |parsed| parsed.path().to_owned(),
224 );
225 let url_path = url_path_owned.as_str();
226
227 let found = {
229 let entries = self.entries.lock().expect("mock registry lock poisoned");
230 entries.iter().find_map(|entry| {
231 let method_ok = entry.method.as_ref().is_none_or(|m| m == method);
232 let path_ok = url_path == entry.path.as_str()
237 || url_path
238 .strip_suffix(entry.path.as_str())
239 .is_some_and(|prefix| {
240 prefix.is_empty()
241 || prefix.ends_with('/')
242 || entry.path.starts_with('/')
243 });
244 let alias_ok = entry
245 .alias
246 .as_deref()
247 .is_none_or(|a| alias.is_some_and(|b| a == b));
248 if method_ok && path_ok && alias_ok {
249 Some((entry.call_count.clone(), entry.status, entry.body.clone()))
250 } else {
251 None
252 }
253 })
254 };
255
256 found.map(|(call_count, status, body)| {
257 call_count.fetch_add(1, Ordering::SeqCst);
258 MockResponse { status, body }
259 })
260 }
261}
262
263impl Default for MockRegistry {
264 fn default() -> Self {
265 Self::new()
266 }
267}
268
269pub struct HttpMockRegistryExt(pub Arc<MockRegistry>);
272
273pub struct MockHandle {
276 alias: String,
277 method: String,
278 path: String,
279 call_count: Arc<AtomicUsize>,
280}
281
282impl MockHandle {
283 pub fn expect_called(&self, expected: usize) {
289 let actual = self.call_count.load(Ordering::SeqCst);
290 assert_eq!(
291 actual, expected,
292 "http mock for {} {} {} expected {} call(s) but got {}",
293 self.alias, self.method, self.path, expected, actual,
294 );
295 }
296
297 #[must_use]
299 pub fn call_count(&self) -> usize {
300 self.call_count.load(Ordering::SeqCst)
301 }
302}
303
304pub struct MockSetupBuilder {
310 pub(crate) registry: Arc<MockRegistry>,
311 pub(crate) alias: String,
312 pub(crate) method: Option<Method>,
313 pub(crate) path: Option<String>,
314}
315
316impl MockSetupBuilder {
317 #[must_use]
319 pub fn get(mut self, path: &str) -> Self {
320 self.method = Some(Method::GET);
321 self.path = Some(path.to_owned());
322 self
323 }
324 #[must_use]
326 pub fn post(mut self, path: &str) -> Self {
327 self.method = Some(Method::POST);
328 self.path = Some(path.to_owned());
329 self
330 }
331 #[must_use]
333 pub fn put(mut self, path: &str) -> Self {
334 self.method = Some(Method::PUT);
335 self.path = Some(path.to_owned());
336 self
337 }
338 #[must_use]
340 pub fn patch(mut self, path: &str) -> Self {
341 self.method = Some(Method::PATCH);
342 self.path = Some(path.to_owned());
343 self
344 }
345 #[must_use]
347 pub fn delete(mut self, path: &str) -> Self {
348 self.method = Some(Method::DELETE);
349 self.path = Some(path.to_owned());
350 self
351 }
352
353 #[must_use]
358 pub fn respond_with(self, status: u16, body: serde_json::Value) -> MockHandle {
359 let path = self.path.clone().unwrap_or_default();
360 let method_str = self
361 .method
362 .as_ref()
363 .map_or_else(|| "*".to_owned(), ToString::to_string);
364 let call_count = Arc::new(AtomicUsize::new(0));
365
366 self.registry.register(MockEntry {
367 method: self.method,
368 path: path.clone(),
369 alias: Some(self.alias.clone()),
370 status,
371 body: Some(body),
372 call_count: call_count.clone(),
373 });
374
375 MockHandle {
376 alias: self.alias,
377 method: method_str,
378 path,
379 call_count,
380 }
381 }
382
383 #[must_use]
388 pub fn respond_with_status(self, status: u16) -> MockHandle {
389 let path = self.path.clone().unwrap_or_default();
390 let method_str = self
391 .method
392 .as_ref()
393 .map_or_else(|| "*".to_owned(), ToString::to_string);
394 let call_count = Arc::new(AtomicUsize::new(0));
395
396 self.registry.register(MockEntry {
397 method: self.method,
398 path: path.clone(),
399 alias: Some(self.alias.clone()),
400 status,
401 body: None,
402 call_count: call_count.clone(),
403 });
404
405 MockHandle {
406 alias: self.alias,
407 method: method_str,
408 path,
409 call_count,
410 }
411 }
412}
413
414#[derive(Clone)]
442pub struct Client {
443 inner: reqwest::Client,
444 alias: Option<String>,
446 base_url: Option<String>,
448 base_urls: HashMap<String, String>,
450 retry_policy: RetryPolicy,
451 mock: Option<Arc<MockRegistry>>,
453 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
455}
456
457impl Client {
458 #[must_use]
461 pub fn new() -> Self {
462 Self::with_timeout(Duration::from_secs(30))
463 }
464
465 #[must_use]
472 pub fn with_timeout(timeout: Duration) -> Self {
473 let inner = reqwest::ClientBuilder::new()
474 .timeout(timeout)
475 .build()
476 .expect("failed to build reqwest client");
477 Self {
478 inner,
479 alias: None,
480 base_url: None,
481 base_urls: HashMap::new(),
482 retry_policy: RetryPolicy {
483 max_retries: 3,
484 retry_idempotent_only: true,
485 max_retry_after: Duration::from_secs(10),
486 request_timeout: Some(timeout),
487 },
488 mock: None,
489 resilience_config: None,
490 }
491 }
492
493 #[must_use]
500 pub fn from_config(config: &crate::config::HttpClientConfig) -> Self {
501 let timeout = Duration::from_secs(config.timeout_secs);
502 let inner = reqwest::ClientBuilder::new()
503 .timeout(timeout)
504 .build()
505 .expect("failed to build reqwest client");
506 Self {
507 inner,
508 alias: None,
509 base_url: None,
510 base_urls: config.base_urls.clone(),
511 retry_policy: RetryPolicy {
512 max_retries: config.max_retries,
513 retry_idempotent_only: true,
514 max_retry_after: Duration::from_secs(config.max_retry_after_secs),
515 request_timeout: Some(timeout),
516 },
517 mock: None,
518 resilience_config: None,
519 }
520 }
521
522 pub(crate) fn with_mock(mut self, registry: Arc<MockRegistry>) -> Self {
524 self.mock = Some(registry);
525 self
526 }
527
528 pub(crate) fn from_state(state: &crate::AppState) -> Self {
530 let config = state.extension::<crate::config::HttpConfig>().or_else(|| {
531 state
532 .extension::<crate::config::AutumnConfig>()
533 .map(|c| Arc::new(c.http.clone()))
534 });
535 let mut client = config.map_or_else(Self::new, |cfg| Self::from_config(&cfg.client));
536
537 let resilience = state
538 .extension::<crate::config::AutumnConfig>()
539 .map(|c| Arc::new(c.resilience.clone()));
540 client.resilience_config = resilience;
541
542 if let Some(ext) = state.extension::<HttpMockRegistryExt>() {
543 client = client.with_mock(ext.0.clone());
544 }
545
546 client
547 }
548
549 #[must_use]
556 pub fn named(&self, alias: &str) -> Self {
557 let base_url = self
558 .base_urls
559 .get(alias)
560 .cloned()
561 .or_else(|| self.base_url.clone());
562 Self {
563 inner: self.inner.clone(),
564 alias: Some(alias.to_owned()),
565 base_url,
566 base_urls: self.base_urls.clone(),
567 retry_policy: self.retry_policy.clone(),
568 mock: self.mock.clone(),
569 resilience_config: self.resilience_config.clone(),
570 }
571 }
572
573 #[must_use]
575 pub fn with_base_url(&self, base_url: impl Into<String>) -> Self {
576 Self {
577 inner: self.inner.clone(),
578 alias: self.alias.clone(),
579 base_url: Some(base_url.into()),
580 base_urls: self.base_urls.clone(),
581 retry_policy: self.retry_policy.clone(),
582 mock: self.mock.clone(),
583 resilience_config: self.resilience_config.clone(),
584 }
585 }
586
587 fn build_request(&self, method: Method, url: impl AsRef<str>) -> RequestBuilder {
588 let url_str = url.as_ref();
589 let full_url = if url_str.starts_with("http://") || url_str.starts_with("https://") {
590 url_str.to_owned()
591 } else if let Some(base) = &self.base_url {
592 format!(
593 "{}/{}",
594 base.trim_end_matches('/'),
595 url_str.trim_start_matches('/')
596 )
597 } else {
598 url_str.to_owned()
599 };
600
601 RequestBuilder {
602 client: self.inner.clone(),
603 method,
604 url: full_url,
605 extra_headers: HeaderMap::new(),
606 body: None,
607 retry_policy: self.retry_policy.clone(),
608 mock: self.mock.clone(),
609 alias: self.alias.clone(),
610 pending_error: None,
611 resilience_config: self.resilience_config.clone(),
612 }
613 }
614
615 #[must_use]
617 pub fn get(&self, url: impl AsRef<str>) -> RequestBuilder {
618 self.build_request(Method::GET, url)
619 }
620 #[must_use]
622 pub fn post(&self, url: impl AsRef<str>) -> RequestBuilder {
623 self.build_request(Method::POST, url)
624 }
625 #[must_use]
627 pub fn put(&self, url: impl AsRef<str>) -> RequestBuilder {
628 self.build_request(Method::PUT, url)
629 }
630 #[must_use]
632 pub fn patch(&self, url: impl AsRef<str>) -> RequestBuilder {
633 self.build_request(Method::PATCH, url)
634 }
635 #[must_use]
637 pub fn delete(&self, url: impl AsRef<str>) -> RequestBuilder {
638 self.build_request(Method::DELETE, url)
639 }
640}
641
642impl Default for Client {
643 fn default() -> Self {
644 Self::new()
645 }
646}
647
648impl axum::extract::FromRequestParts<crate::AppState> for Client {
649 type Rejection = std::convert::Infallible;
650
651 async fn from_request_parts(
652 _parts: &mut http::request::Parts,
653 state: &crate::AppState,
654 ) -> Result<Self, std::convert::Infallible> {
655 Ok(Self::from_state(state))
656 }
657}
658
659pub struct RequestBuilder {
663 client: reqwest::Client,
664 method: Method,
665 url: String,
666 extra_headers: HeaderMap,
667 body: Option<Bytes>,
669 retry_policy: RetryPolicy,
670 mock: Option<Arc<MockRegistry>>,
671 alias: Option<String>,
672 pending_error: Option<ClientError>,
674 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
676}
677
678impl RequestBuilder {
679 #[must_use]
685 pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
686 let name_str = name.as_ref();
687 let value_str = value.as_ref();
688 match (
689 HeaderName::from_bytes(name_str.as_bytes()),
690 HeaderValue::from_str(value_str),
691 ) {
692 (Ok(n), Ok(v)) => {
693 self.extra_headers.insert(n, v);
694 }
695 (Err(e), _) => {
696 tracing::warn!(header.name = name_str, error = %e, "invalid header name — header skipped");
697 }
698 (_, Err(e)) => {
699 tracing::warn!(header.name = name_str, error = %e, "invalid header value — header skipped");
700 }
701 }
702 self
703 }
704
705 #[must_use]
710 pub fn json<T: Serialize>(mut self, body: &T) -> Self {
711 match serde_json::to_vec(body) {
712 Ok(bytes) => {
713 self.body = Some(Bytes::from(bytes));
714 self = self.header("content-type", "application/json");
715 }
716 Err(e) => {
717 self.pending_error = Some(ClientError::Json(e));
718 }
719 }
720 self
721 }
722
723 #[must_use]
725 pub fn text_body(mut self, body: impl Into<String>) -> Self {
726 self.body = Some(Bytes::from(body.into().into_bytes()));
727 self
728 }
729
730 #[must_use]
735 pub const fn retries(mut self, max: u32) -> Self {
736 self.retry_policy.max_retries = max;
737 self.retry_policy.retry_idempotent_only = false;
738 self
739 }
740
741 #[must_use]
743 pub const fn max_retry_after(mut self, max: Duration) -> Self {
744 self.retry_policy.max_retry_after = max;
745 self
746 }
747
748 #[must_use]
750 pub const fn no_retry(mut self) -> Self {
751 self.retry_policy.max_retries = 0;
752 self
753 }
754
755 pub async fn send(self) -> Result<Response, ClientError> {
769 if let Some(err) = self.pending_error {
771 return Err(err);
772 }
773
774 if self.mock.is_some() {
776 return self.send_inner(false).await;
777 }
778
779 let host = url::Url::parse(&self.url).ok().map_or_else(
781 || "unknown".to_owned(),
782 |u| {
783 let h = u.host_str().unwrap_or("unknown");
784 u.port()
785 .map_or_else(|| h.to_owned(), |port| format!("{h}:{port}"))
786 },
787 );
788
789 let breaker = self.resilience_config.as_ref().map_or_else(
790 || {
791 crate::circuit_breaker::global_registry().get_or_create(
792 &host,
793 crate::circuit_breaker::CircuitBreakerPolicy::default(),
794 )
795 },
796 |rc| {
797 let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(rc, &host);
798 crate::circuit_breaker::global_registry().get_or_create_with_config(&host, policy)
799 },
800 );
801
802 if breaker.before_call().is_err() {
804 return Err(ClientError::CircuitBreakerOpen);
805 }
806 let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
807
808 let is_half_open = breaker.state() == crate::circuit_breaker::CircuitState::HalfOpen;
809 let res = self.send_inner(is_half_open).await;
810 match &res {
811 Ok(resp) => {
812 let success = resp.status().as_u16() < 500;
813 if success {
814 guard.success();
815 } else {
816 guard.failure();
817 }
818 }
819 Err(_) => {
820 guard.failure();
821 }
822 }
823 res
824 }
825
826 async fn send_inner(self, suppress_retries: bool) -> Result<Response, ClientError> {
827 if let Some(ref mock) = self.mock {
829 match mock.find_match(&self.method, &self.url, self.alias.as_deref()) {
830 Some(mock_resp) => {
831 let status = reqwest::StatusCode::from_u16(mock_resp.status)
832 .unwrap_or(reqwest::StatusCode::OK);
833 let body_bytes = mock_resp
834 .body
835 .as_ref()
836 .map(|v| serde_json::to_vec(v).unwrap_or_default())
837 .unwrap_or_default();
838
839 tracing::info!(
840 http.method = %self.method,
841 http.url = %self.url,
842 http.status = mock_resp.status,
843 "[mock] outbound request intercepted"
844 );
845
846 return Ok(Response {
847 status,
848 headers: HeaderMap::new(),
849 body: Bytes::from(body_bytes),
850 url: None,
851 });
852 }
853 None => {
854 return Err(ClientError::NoMock(
857 self.method.to_string(),
858 self.url.clone(),
859 ));
860 }
861 }
862 }
863
864 let start = Instant::now();
866 let max_attempts = if suppress_retries {
867 1
868 } else if is_idempotent_method(&self.method) || !self.retry_policy.retry_idempotent_only {
869 self.retry_policy.max_retries.saturating_add(1)
870 } else {
871 1
872 };
873
874 for attempt in 0..max_attempts {
875 if attempt > 0 {
876 let exp = (attempt - 1).min(10);
878 let delay = Duration::from_millis(100 * (1_u64 << exp));
879 tokio::time::sleep(delay).await;
880 }
881
882 let mut req = self.client.request(self.method.clone(), &self.url);
883
884 req = inject_trace_context(req);
886
887 for (name, value) in &self.extra_headers {
889 req = req.header(name.clone(), value.clone());
890 }
891
892 if let Some(body) = &self.body {
893 req = req.body(body.clone());
894 }
895
896 match req.send().await {
897 Ok(resp) => {
898 let status = resp.status();
899 let headers = resp.headers().clone();
900 let url_used = resp.url().clone();
901
902 if status.as_u16() == 429 && attempt + 1 < max_attempts {
904 let mut sleep_delay =
905 parse_retry_after(&headers).unwrap_or(Duration::from_secs(1));
906 sleep_delay = sleep_delay.min(self.retry_policy.max_retry_after);
907 if let Some(req_timeout) = self.retry_policy.request_timeout {
908 sleep_delay = sleep_delay.min(req_timeout);
909 }
910 tokio::time::sleep(sleep_delay).await;
911 continue;
912 }
913
914 if is_retryable_status(status.as_u16()) && attempt + 1 < max_attempts {
916 continue;
917 }
918
919 let body = resp
920 .bytes()
921 .await
922 .map_err(|e| ClientError::Request(e.without_url()))?;
923 let elapsed = start.elapsed();
924 log_request(
925 self.method.as_str(),
926 &url_used,
927 status.as_u16(),
928 elapsed,
929 &self.extra_headers,
930 );
931
932 return Ok(Response {
933 status,
934 headers,
935 body,
936 url: Some(url_used),
937 });
938 }
939 Err(e) if (e.is_connect() || e.is_timeout()) && attempt + 1 < max_attempts => {}
942 Err(e) => return Err(ClientError::Request(e.without_url())),
943 }
944 }
945
946 unreachable!("retry loop exited without returning a result — this is a bug")
948 }
949}
950
951const fn is_idempotent_method(method: &Method) -> bool {
954 matches!(
955 *method,
956 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS | Method::TRACE
957 )
958}
959
960const fn is_retryable_status(status: u16) -> bool {
961 matches!(status, 502..=504)
962}
963
964fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
965 let value = headers.get("retry-after")?.to_str().ok()?;
966 if let Ok(secs) = value.parse::<u64>() {
968 return Some(Duration::from_secs(secs));
969 }
970 let dt = chrono::DateTime::parse_from_rfc2822(value).ok()?;
972 let now = chrono::Utc::now();
973 let future = dt.with_timezone(&chrono::Utc);
974 let secs = u64::try_from((future - now).num_seconds().max(0)).unwrap_or(0);
975 Some(Duration::from_secs(secs))
976}
977
978const REDACTED_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie"];
979
980fn is_sensitive_header(name: &str) -> bool {
981 REDACTED_HEADERS
982 .iter()
983 .any(|h| h.eq_ignore_ascii_case(name))
984}
985
986fn log_request(
987 method: &str,
988 url: &reqwest::Url,
989 status: u16,
990 elapsed: Duration,
991 headers: &HeaderMap,
992) {
993 let host = url.host_str().unwrap_or("unknown");
994 let path = url.path();
995
996 let sent_headers: Vec<&str> = headers
998 .keys()
999 .map(HeaderName::as_str)
1000 .filter(|k| !is_sensitive_header(k))
1001 .collect();
1002
1003 tracing::info!(
1004 http.method = method,
1005 http.host = host,
1006 http.path = path,
1007 http.status = status,
1008 http.elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
1009 http.sent_headers = ?sent_headers,
1010 "outbound request"
1011 );
1012}
1013
1014#[allow(clippy::missing_const_for_fn)]
1018fn inject_trace_context(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1019 #[cfg(not(feature = "telemetry-otlp"))]
1020 {
1021 builder
1022 }
1023 #[cfg(feature = "telemetry-otlp")]
1024 {
1025 use std::collections::HashMap;
1026 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
1027 let cx = tracing::Span::current().context();
1028 let mut map = HashMap::<String, String>::new();
1029 opentelemetry::global::get_text_map_propagator(|propagator| {
1030 propagator.inject_context(&cx, &mut TraceHeaderInjector(&mut map));
1031 });
1032 let mut builder = builder;
1033 for (k, v) in map {
1034 if let Ok(value) = HeaderValue::from_str(&v) {
1035 builder = builder.header(k, value);
1036 }
1037 }
1038 builder
1039 }
1040}
1041
1042#[cfg(feature = "telemetry-otlp")]
1043struct TraceHeaderInjector<'a>(&'a mut std::collections::HashMap<String, String>);
1044
1045#[cfg(feature = "telemetry-otlp")]
1046impl opentelemetry::propagation::Injector for TraceHeaderInjector<'_> {
1047 fn set(&mut self, key: &str, value: String) {
1048 self.0.insert(key.to_owned(), value);
1049 }
1050}
1051
1052#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use crate::config::HttpClientConfig;
1058
1059 #[test]
1061 fn client_constructs_with_defaults() {
1062 let client = Client::new();
1063 assert!(client.alias.is_none());
1064 assert!(client.base_url.is_none());
1065 assert_eq!(client.retry_policy.max_retries, 3);
1066 }
1067
1068 #[test]
1070 fn request_builder_fluent_api_compiles() {
1071 let client = Client::new();
1072 let _builder = client
1073 .post("https://example.com/api")
1074 .header("x-api-key", "secret")
1075 .json(&serde_json::json!({"key": "value"}))
1076 .retries(2);
1077 }
1078
1079 #[test]
1081 fn response_accessors_work() {
1082 let payload = serde_json::json!({"id": 42, "name": "Alice"});
1083 let body = serde_json::to_vec(&payload).unwrap();
1084 let resp = Response {
1085 status: reqwest::StatusCode::OK,
1086 headers: HeaderMap::new(),
1087 body: Bytes::from(body),
1088 url: None,
1089 };
1090 assert_eq!(resp.status().as_u16(), 200);
1091 assert!(resp.is_success());
1092 }
1093
1094 #[test]
1096 fn response_json_deserialises() {
1097 #[derive(serde::Deserialize, PartialEq, Debug)]
1098 struct User {
1099 id: i32,
1100 name: String,
1101 }
1102 let payload = serde_json::json!({"id": 1, "name": "Bob"});
1103 let resp = Response {
1104 status: reqwest::StatusCode::OK,
1105 headers: HeaderMap::new(),
1106 body: Bytes::from(serde_json::to_vec(&payload).unwrap()),
1107 url: None,
1108 };
1109 let user: User = resp.json().unwrap();
1110 assert_eq!(user.id, 1);
1111 assert_eq!(user.name, "Bob");
1112 }
1113
1114 #[test]
1116 fn response_text_returns_string() {
1117 let resp = Response {
1118 status: reqwest::StatusCode::OK,
1119 headers: HeaderMap::new(),
1120 body: Bytes::from_static(b"hello world"),
1121 url: None,
1122 };
1123 assert_eq!(resp.text(), "hello world");
1124 }
1125
1126 #[test]
1128 fn response_bytes_returns_raw() {
1129 let resp = Response {
1130 status: reqwest::StatusCode::CREATED,
1131 headers: HeaderMap::new(),
1132 body: Bytes::from_static(b"\x00\x01\x02"),
1133 url: None,
1134 };
1135 assert_eq!(resp.bytes(), Bytes::from_static(b"\x00\x01\x02"));
1136 }
1137
1138 #[test]
1140 fn config_deserialises_from_toml() {
1141 let toml = r#"
1143 [client]
1144 timeout_secs = 60
1145 max_retries = 5
1146 [client.base_urls]
1147 stripe = "https://api.stripe.com"
1148 sendgrid = "https://api.sendgrid.com"
1149 "#;
1150 let http_cfg: crate::config::HttpConfig = toml::from_str(toml).unwrap();
1151 let config = &http_cfg.client;
1152 assert_eq!(config.timeout_secs, 60);
1153 assert_eq!(config.max_retries, 5);
1154 assert_eq!(
1155 config.base_urls.get("stripe").map(String::as_str),
1156 Some("https://api.stripe.com")
1157 );
1158 assert_eq!(
1159 config.base_urls.get("sendgrid").map(String::as_str),
1160 Some("https://api.sendgrid.com")
1161 );
1162 }
1163
1164 #[test]
1166 fn config_has_correct_defaults() {
1167 let config = HttpClientConfig::default();
1168 assert_eq!(config.timeout_secs, 30);
1169 assert_eq!(config.max_retries, 3);
1170 assert!(config.base_urls.is_empty());
1171 }
1172
1173 #[test]
1175 fn idempotent_method_classification() {
1176 assert!(is_idempotent_method(&Method::GET));
1177 assert!(is_idempotent_method(&Method::HEAD));
1178 assert!(is_idempotent_method(&Method::PUT));
1179 assert!(is_idempotent_method(&Method::DELETE));
1180 assert!(is_idempotent_method(&Method::OPTIONS));
1181 assert!(is_idempotent_method(&Method::TRACE));
1182 assert!(!is_idempotent_method(&Method::POST));
1183 assert!(!is_idempotent_method(&Method::PATCH));
1184 }
1185
1186 #[test]
1188 fn retryable_status_classification() {
1189 assert!(is_retryable_status(502));
1190 assert!(is_retryable_status(503));
1191 assert!(is_retryable_status(504));
1192 assert!(!is_retryable_status(200));
1193 assert!(!is_retryable_status(400));
1194 assert!(!is_retryable_status(404));
1195 assert!(!is_retryable_status(500));
1196 assert!(!is_retryable_status(429));
1197 }
1198
1199 #[test]
1201 fn retry_after_header_parsing() {
1202 let mut headers = HeaderMap::new();
1203 headers.insert(
1204 reqwest::header::HeaderName::from_static("retry-after"),
1205 HeaderValue::from_static("5"),
1206 );
1207 assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(5)));
1208
1209 let empty = HeaderMap::new();
1210 assert_eq!(parse_retry_after(&empty), None);
1211 }
1212
1213 #[test]
1215 fn sensitive_header_detection() {
1216 assert!(is_sensitive_header("authorization"));
1217 assert!(is_sensitive_header("Authorization"));
1218 assert!(is_sensitive_header("AUTHORIZATION"));
1219 assert!(is_sensitive_header("cookie"));
1220 assert!(is_sensitive_header("set-cookie"));
1221 assert!(!is_sensitive_header("content-type"));
1222 assert!(!is_sensitive_header("x-api-key"));
1223 }
1224
1225 #[tokio::test]
1227 async fn mock_registry_captures_calls() {
1228 let registry = Arc::new(MockRegistry::new());
1229 let call_count = Arc::new(AtomicUsize::new(0));
1230
1231 registry.register(MockEntry {
1232 method: Some(Method::POST),
1233 path: "/charges".to_owned(),
1234 alias: Some("stripe".to_owned()),
1235 status: 200,
1236 body: Some(serde_json::json!({"id": "ch_123"})),
1237 call_count: call_count.clone(),
1238 });
1239
1240 let client = Client::new().with_mock(registry).named("stripe");
1241
1242 let resp = client
1243 .post("https://api.stripe.com/charges")
1244 .json(&serde_json::json!({"amount": 1000}))
1245 .send()
1246 .await
1247 .unwrap();
1248
1249 assert_eq!(resp.status().as_u16(), 200);
1250 let body: serde_json::Value = resp.json().unwrap();
1251 assert_eq!(body["id"], "ch_123");
1252 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1253 }
1254
1255 #[tokio::test]
1257 async fn mock_handle_expect_called_passes() {
1258 let registry = Arc::new(MockRegistry::new());
1259 let call_count = Arc::new(AtomicUsize::new(0));
1260
1261 registry.register(MockEntry {
1262 method: Some(Method::GET),
1263 path: "/users/1".to_owned(),
1264 alias: None,
1265 status: 200,
1266 body: Some(serde_json::json!({"name": "Alice"})),
1267 call_count: call_count.clone(),
1268 });
1269
1270 let handle = MockHandle {
1271 alias: "test".to_owned(),
1272 method: "GET".to_owned(),
1273 path: "/users/1".to_owned(),
1274 call_count: call_count.clone(),
1275 };
1276
1277 let client = Client::new().with_mock(registry);
1278 client
1279 .get("https://api.example.com/users/1")
1280 .send()
1281 .await
1282 .unwrap();
1283
1284 handle.expect_called(1);
1285 assert_eq!(handle.call_count(), 1);
1286 }
1287
1288 #[tokio::test]
1290 async fn mock_matches_by_path_suffix() {
1291 let registry = Arc::new(MockRegistry::new());
1292 let call_count = Arc::new(AtomicUsize::new(0));
1293
1294 registry.register(MockEntry {
1295 method: Some(Method::POST),
1296 path: "/v1/charges".to_owned(),
1297 alias: None,
1298 status: 201,
1299 body: Some(serde_json::json!({"created": true})),
1300 call_count: call_count.clone(),
1301 });
1302
1303 let client = Client::new().with_mock(registry);
1304 let resp = client
1305 .post("https://api.stripe.com/v1/charges")
1306 .send()
1307 .await
1308 .unwrap();
1309
1310 assert_eq!(resp.status().as_u16(), 201);
1311 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1312 }
1313
1314 #[tokio::test]
1316 async fn no_mock_error_when_unmatched() {
1317 let registry = Arc::new(MockRegistry::new());
1318 let client = Client::new().with_mock(registry);
1319 let result = client.post("https://api.example.com/unknown").send().await;
1320 assert!(matches!(result, Err(ClientError::NoMock(_, _))));
1321 }
1322
1323 #[tokio::test]
1325 async fn mock_setup_builder_registers_entry() {
1326 let registry = Arc::new(MockRegistry::new());
1327 let builder = MockSetupBuilder {
1328 registry: registry.clone(),
1329 alias: "myservice".to_owned(),
1330 method: None,
1331 path: None,
1332 };
1333
1334 let handle = builder
1335 .post("/api/resource")
1336 .respond_with(201, serde_json::json!({"ok": true}));
1337
1338 let client = Client::new().with_mock(registry).named("myservice");
1339 client
1340 .post("https://myservice.example.com/api/resource")
1341 .send()
1342 .await
1343 .unwrap();
1344
1345 handle.expect_called(1);
1346 }
1347
1348 #[test]
1350 fn client_from_config() {
1351 let config = HttpClientConfig {
1352 timeout_secs: 10,
1353 max_retries: 1,
1354 max_retry_after_secs: 10,
1355 base_urls: std::collections::HashMap::new(),
1356 };
1357 let client = Client::from_config(&config);
1358 assert_eq!(client.retry_policy.max_retries, 1);
1359 }
1360
1361 #[test]
1363 fn named_client_preserves_mock_registry() {
1364 let registry = Arc::new(MockRegistry::new());
1365 let client = Client::new().with_mock(registry);
1366 let named = client.named("stripe");
1367 assert!(named.mock.is_some());
1368 assert_eq!(named.alias.as_deref(), Some("stripe"));
1369 }
1370
1371 #[test]
1373 fn base_url_prepended_to_relative_path() {
1374 let client = Client::new();
1375 let client = client.with_base_url("https://api.stripe.com");
1376 let builder = client.post("/v1/charges");
1377 assert_eq!(builder.url, "https://api.stripe.com/v1/charges");
1378 }
1379
1380 #[test]
1382 fn absolute_url_bypasses_base_url() {
1383 let client = Client::new().with_base_url("https://ignored.example.com");
1384 let builder = client.get("https://actual.example.com/path");
1385 assert_eq!(builder.url, "https://actual.example.com/path");
1386 }
1387
1388 #[test]
1390 fn retry_override_per_request() {
1391 let client = Client::new(); let builder = client.get("https://example.com").retries(0);
1393 assert_eq!(builder.retry_policy.max_retries, 0);
1394
1395 let no_retry = client.get("https://example.com").no_retry();
1396 assert_eq!(no_retry.retry_policy.max_retries, 0);
1397 }
1398
1399 #[tokio::test]
1401 async fn client_extracts_from_state() {
1402 use axum::extract::FromRequestParts;
1403 let state = crate::AppState::for_test();
1404 let mut parts = axum::http::Request::new(axum::body::Body::empty())
1405 .into_parts()
1406 .0;
1407 let client = Client::from_request_parts(&mut parts, &state)
1408 .await
1409 .unwrap();
1410 assert!(client.mock.is_none());
1412 assert!(client.alias.is_none());
1413 }
1414
1415 #[test]
1417 fn mock_registry_ext_round_trips_through_state() {
1418 let registry = Arc::new(MockRegistry::new());
1419 let ext = HttpMockRegistryExt(registry);
1420 let state = crate::AppState::for_test();
1421 state.insert_extension(ext);
1422 let retrieved = state.extension::<HttpMockRegistryExt>();
1423 assert!(retrieved.is_some());
1424 }
1425
1426 #[test]
1428 fn named_client_resolves_base_url_from_config() {
1429 let mut base_urls = std::collections::HashMap::new();
1430 base_urls.insert("stripe".to_owned(), "https://api.stripe.com".to_owned());
1431 let config = HttpClientConfig {
1432 timeout_secs: 30,
1433 max_retries: 3,
1434 max_retry_after_secs: 10,
1435 base_urls,
1436 };
1437 let client = Client::from_config(&config);
1438 let stripe = client.named("stripe");
1439 assert_eq!(stripe.base_url.as_deref(), Some("https://api.stripe.com"));
1440 assert_eq!(stripe.alias.as_deref(), Some("stripe"));
1441
1442 let other = client.named("sendgrid");
1444 assert!(other.base_url.is_none());
1445 }
1446
1447 #[tokio::test]
1449 async fn client_extracts_from_autumn_config_in_state() {
1450 use axum::extract::FromRequestParts;
1451 let mut cfg = crate::config::AutumnConfig::default();
1452 cfg.http.client.max_retries = 7;
1453 let state = crate::AppState::for_test();
1454 state.insert_extension(cfg);
1455
1456 let mut parts = axum::http::Request::new(axum::body::Body::empty())
1457 .into_parts()
1458 .0;
1459 let client = Client::from_request_parts(&mut parts, &state)
1460 .await
1461 .unwrap();
1462 assert_eq!(client.retry_policy.max_retries, 7);
1463 }
1464
1465 #[tokio::test]
1467 async fn respond_with_status_produces_empty_body() {
1468 let registry = Arc::new(MockRegistry::new());
1469 let builder = MockSetupBuilder {
1470 registry: registry.clone(),
1471 alias: "svc".to_owned(),
1472 method: None,
1473 path: None,
1474 };
1475 let _handle = builder.delete("/items/1").respond_with_status(204);
1476
1477 let client = Client::new().with_mock(registry).named("svc");
1478 let resp = client
1479 .delete("https://svc.example.com/items/1")
1480 .send()
1481 .await
1482 .unwrap();
1483
1484 assert_eq!(resp.status().as_u16(), 204);
1485 assert_eq!(
1486 resp.bytes(),
1487 bytes::Bytes::new(),
1488 "body must be empty, not \"null\""
1489 );
1490 }
1491
1492 #[test]
1494 fn retry_after_http_date_parsing() {
1495 let mut headers = HeaderMap::new();
1496 headers.insert(
1498 reqwest::header::HeaderName::from_static("retry-after"),
1499 HeaderValue::from_static("Tue, 01 Jan 2030 00:00:00 GMT"),
1500 );
1501 let duration = parse_retry_after(&headers);
1502 assert!(duration.is_some(), "should parse HTTP-date Retry-After");
1503 assert!(
1504 duration.unwrap().as_secs() > 0,
1505 "future date should yield positive delay"
1506 );
1507 }
1508
1509 #[tokio::test]
1511 async fn non_idempotent_post_no_retry() {
1512 let registry = Arc::new(MockRegistry::new());
1513 let call_count = Arc::new(AtomicUsize::new(0));
1514 registry.register(MockEntry {
1515 method: Some(Method::POST),
1516 path: "/endpoint".to_owned(),
1517 alias: None,
1518 status: 503,
1519 body: None,
1520 call_count: call_count.clone(),
1521 });
1522
1523 let client = Client::new().with_mock(registry);
1525 let resp = client
1526 .post("https://example.com/endpoint")
1527 .send()
1528 .await
1529 .unwrap();
1530
1531 assert_eq!(resp.status().as_u16(), 503);
1532 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1534 }
1535
1536 #[tokio::test]
1538 async fn mock_strips_query_from_url_before_matching() {
1539 let registry = Arc::new(MockRegistry::new());
1540 let call_count = Arc::new(AtomicUsize::new(0));
1541 registry.register(MockEntry {
1542 method: Some(Method::GET),
1543 path: "/v1/charges".to_owned(),
1544 alias: None,
1545 status: 200,
1546 body: Some(serde_json::json!({"ok": true})),
1547 call_count: call_count.clone(),
1548 });
1549
1550 let client = Client::new().with_mock(registry);
1552 let resp = client
1553 .get("https://api.stripe.com/v1/charges?expand[]=balance_transaction")
1554 .send()
1555 .await
1556 .unwrap();
1557
1558 assert_eq!(resp.status().as_u16(), 200);
1559 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1560 }
1561
1562 #[tokio::test]
1564 async fn mock_suffix_match_with_leading_slash_path() {
1565 let registry = Arc::new(MockRegistry::new());
1566 let call_count = Arc::new(AtomicUsize::new(0));
1567 registry.register(MockEntry {
1569 method: Some(Method::POST),
1570 path: "/charges".to_owned(),
1571 alias: None,
1572 status: 201,
1573 body: Some(serde_json::json!({"matched": true})),
1574 call_count: call_count.clone(),
1575 });
1576
1577 let client = Client::new().with_mock(registry);
1578 let resp = client
1580 .post("https://api.stripe.com/v1/charges")
1581 .send()
1582 .await
1583 .unwrap();
1584
1585 assert_eq!(resp.status().as_u16(), 201);
1586 assert_eq!(call_count.load(Ordering::SeqCst), 1);
1587 }
1588
1589 #[test]
1591 fn retries_clears_idempotent_only_flag() {
1592 let client = Client::new();
1593 let builder = client.post("https://example.com").retries(2);
1594 assert_eq!(builder.retry_policy.max_retries, 2);
1595 assert!(
1596 !builder.retry_policy.retry_idempotent_only,
1597 "explicit retries() call must allow non-idempotent methods to retry"
1598 );
1599 }
1600
1601 #[test]
1603 fn log_request_completes_with_sensitive_headers() {
1604 let url = reqwest::Url::parse("https://api.example.com/v1/resource?q=1").unwrap();
1605 let mut headers = HeaderMap::new();
1606 headers.insert(
1607 HeaderName::from_static("content-type"),
1608 HeaderValue::from_static("application/json"),
1609 );
1610 headers.insert(
1611 HeaderName::from_static("authorization"),
1612 HeaderValue::from_static("Bearer sk_test_xxx"),
1613 );
1614 log_request("POST", &url, 201, Duration::from_millis(12), &headers);
1616 }
1617
1618 #[test]
1620 fn inject_trace_context_passthrough_without_telemetry() {
1621 let inner = reqwest::Client::new();
1622 let builder = inner.get("https://example.com");
1623 let _b = inject_trace_context(builder);
1625 }
1626
1627 #[tokio::test]
1630 #[allow(clippy::await_holding_lock)]
1631 async fn real_get_request_covers_network_path() {
1632 use axum::{Router, routing::get};
1633
1634 let _lock = crate::circuit_breaker::TEST_LOCK
1635 .lock()
1636 .unwrap_or_else(std::sync::PoisonError::into_inner);
1637 crate::circuit_breaker::global_registry().clear();
1638
1639 let app = Router::new().route("/ping", get(|| async { "pong" }));
1640 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1641 let addr = listener.local_addr().unwrap();
1642 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1643
1644 let client = Client::new();
1645 let resp = client
1646 .get(format!("http://127.0.0.1:{}/ping", addr.port()))
1647 .header("x-request-id", "test-35")
1648 .send()
1649 .await
1650 .unwrap();
1651
1652 assert_eq!(resp.status().as_u16(), 200);
1653 assert!(resp.url().is_some());
1654 assert_eq!(resp.text(), "pong");
1655
1656 crate::circuit_breaker::global_registry().clear();
1657 }
1658
1659 #[tokio::test]
1661 #[allow(clippy::await_holding_lock)]
1662 async fn real_post_with_json_body_covers_body_path() {
1663 use axum::{Json, Router, routing::post};
1664 use serde_json::Value;
1665
1666 let _lock = crate::circuit_breaker::TEST_LOCK
1667 .lock()
1668 .unwrap_or_else(std::sync::PoisonError::into_inner);
1669 crate::circuit_breaker::global_registry().clear();
1670
1671 let app = Router::new().route(
1672 "/echo",
1673 post(|Json(body): Json<Value>| async move { Json(body) }),
1674 );
1675 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1676 let addr = listener.local_addr().unwrap();
1677 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1678
1679 let client = Client::new();
1680 let resp = client
1681 .post(format!("http://127.0.0.1:{}/echo", addr.port()))
1682 .json(&serde_json::json!({"hello": "world"}))
1683 .send()
1684 .await
1685 .unwrap();
1686
1687 assert_eq!(resp.status().as_u16(), 200);
1688 let body: Value = resp.json().unwrap();
1689 assert_eq!(body["hello"], "world");
1690
1691 crate::circuit_breaker::global_registry().clear();
1692 }
1693
1694 #[tokio::test]
1696 #[allow(clippy::await_holding_lock)]
1697 async fn real_get_retries_on_503_then_succeeds() {
1698 use axum::{Router, routing::get};
1699 use std::sync::Arc;
1700 use std::sync::atomic::{AtomicU32, Ordering as SeqOrdering};
1701
1702 let _lock = crate::circuit_breaker::TEST_LOCK
1703 .lock()
1704 .unwrap_or_else(std::sync::PoisonError::into_inner);
1705 crate::circuit_breaker::global_registry().clear();
1706
1707 let hit = Arc::new(AtomicU32::new(0));
1708 let hit2 = hit.clone();
1709 let app = Router::new().route(
1710 "/flaky",
1711 get(move || {
1712 let c = hit2.clone();
1713 async move {
1714 if c.fetch_add(1, SeqOrdering::SeqCst) == 0 {
1715 axum::http::StatusCode::SERVICE_UNAVAILABLE
1716 } else {
1717 axum::http::StatusCode::OK
1718 }
1719 }
1720 }),
1721 );
1722 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1723 let addr = listener.local_addr().unwrap();
1724 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1725
1726 let resp = Client::new()
1728 .get(format!("http://127.0.0.1:{}/flaky", addr.port()))
1729 .retries(1)
1730 .send()
1731 .await
1732 .unwrap();
1733
1734 assert_eq!(resp.status().as_u16(), 200);
1735 assert_eq!(hit.load(SeqOrdering::SeqCst), 2);
1736
1737 crate::circuit_breaker::global_registry().clear();
1738 }
1739
1740 #[test]
1742 fn text_body_sets_body() {
1743 let client = Client::new();
1744 let builder = client.post("https://example.com").text_body("hello");
1745 assert_eq!(builder.body, Some(bytes::Bytes::from_static(b"hello")));
1746 }
1747
1748 #[test]
1750 fn client_error_display() {
1751 let err = ClientError::NoMock("GET".to_owned(), "/path".to_owned());
1752 assert!(err.to_string().contains("GET"));
1753 assert!(err.to_string().contains("/path"));
1754 }
1755
1756 #[tokio::test]
1758 #[allow(clippy::await_holding_lock)]
1759 async fn test_http_client_circuit_breaker_integration() {
1760 use axum::{Router, routing::get};
1761 use std::sync::atomic::{AtomicU32, Ordering as SeqOrdering};
1762
1763 let _lock = crate::circuit_breaker::TEST_LOCK
1764 .lock()
1765 .unwrap_or_else(std::sync::PoisonError::into_inner);
1766 crate::circuit_breaker::global_registry().clear();
1767
1768 let hit = Arc::new(AtomicU32::new(0));
1769 let hit2 = hit.clone();
1770 let app = Router::new().route(
1771 "/flaky",
1772 get(move || {
1773 let c = hit2.clone();
1774 async move {
1775 c.fetch_add(1, SeqOrdering::SeqCst);
1776 axum::http::StatusCode::INTERNAL_SERVER_ERROR
1777 }
1778 }),
1779 );
1780 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1781 let addr = listener.local_addr().unwrap();
1782 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1783
1784 let mut rc = crate::config::ResilienceConfig::default();
1786 rc.circuit_breaker.defaults.failure_ratio_threshold = Some(0.5);
1787 rc.circuit_breaker.defaults.minimum_sample_count = Some(3);
1788 rc.circuit_breaker.defaults.open_duration_secs = Some(10);
1789
1790 let client = Client::new();
1791 let client = Client {
1793 resilience_config: Some(Arc::new(rc)),
1794 ..client
1795 };
1796
1797 let url = format!("http://127.0.0.1:{}/flaky", addr.port());
1798
1799 for _ in 0..3 {
1801 let res = client.get(&url).send().await;
1802 let res = res.unwrap();
1803 assert_eq!(res.status().as_u16(), 500);
1804 }
1805
1806 let res = client.get(&url).send().await;
1808 assert!(matches!(res, Err(ClientError::CircuitBreakerOpen)));
1809
1810 assert_eq!(hit.load(SeqOrdering::SeqCst), 3);
1812 crate::circuit_breaker::global_registry().clear();
1813 }
1814}