1#![cfg(feature = "async")]
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use serde::de::DeserializeOwned;
13
14use crate::auth::{ApiKey, ApiKeySigner, RequestSigner};
15use crate::error::{Error, Result};
16use crate::retry::RetryPolicy;
17
18#[derive(Debug, Clone)]
20pub struct Client {
21 inner: Arc<Inner>,
22}
23
24#[derive(Debug)]
25struct Inner {
26 base_url: String,
27 http: reqwest::Client,
28 user_agent: String,
29 betas: Vec<String>,
30 retry: RetryPolicy,
31 signer: Arc<dyn RequestSigner>,
32}
33
34impl Client {
35 pub fn new(api_key: impl Into<String>) -> Self {
43 Self::builder()
44 .api_key(api_key)
45 .build()
46 .expect("default builder should succeed when an api key is provided")
47 }
48
49 pub fn builder() -> ClientBuilder {
51 ClientBuilder::default()
52 }
53
54 pub fn messages(&self) -> crate::messages::Messages<'_> {
56 crate::messages::Messages::new(self)
57 }
58
59 pub fn models(&self) -> crate::models::Models<'_> {
61 crate::models::Models::new(self)
62 }
63
64 pub fn batches(&self) -> crate::batches::Batches<'_> {
66 crate::batches::Batches::new(self)
67 }
68
69 pub fn files(&self) -> crate::files::Files<'_> {
71 crate::files::Files::new(self)
72 }
73
74 #[cfg(feature = "managed-agents-preview")]
78 #[cfg_attr(docsrs, doc(cfg(feature = "managed-agents-preview")))]
79 pub fn managed_agents(&self) -> crate::managed_agents::ManagedAgents<'_> {
80 crate::managed_agents::ManagedAgents::new(self)
81 }
82
83 #[cfg(feature = "admin")]
87 #[cfg_attr(docsrs, doc(cfg(feature = "admin")))]
88 pub fn admin(&self) -> crate::admin::Admin<'_> {
89 crate::admin::Admin::new(self)
90 }
91
92 #[cfg(feature = "skills")]
96 #[cfg_attr(docsrs, doc(cfg(feature = "skills")))]
97 pub fn skills(&self) -> crate::skills::Skills<'_> {
98 crate::skills::Skills::new(self)
99 }
100
101 #[cfg(feature = "user-profiles")]
105 #[cfg_attr(docsrs, doc(cfg(feature = "user-profiles")))]
106 pub fn user_profiles(&self) -> crate::user_profiles::UserProfiles<'_> {
107 crate::user_profiles::UserProfiles::new(self)
108 }
109
110 pub(crate) fn request_builder(
116 &self,
117 method: reqwest::Method,
118 path: &str,
119 ) -> reqwest::RequestBuilder {
120 let url = format!("{}{}", self.inner.base_url, path);
121 self.inner
122 .http
123 .request(method, url)
124 .header("anthropic-version", crate::ANTHROPIC_VERSION)
125 .header(reqwest::header::USER_AGENT, &self.inner.user_agent)
126 }
127
128 pub(crate) async fn execute<R: DeserializeOwned>(
134 &self,
135 mut builder: reqwest::RequestBuilder,
136 per_request_betas: &[&str],
137 ) -> Result<R> {
138 if let Some(joined) = merge_betas(&self.inner.betas, per_request_betas) {
139 builder = builder.header("anthropic-beta", joined);
140 }
141
142 let mut request = builder.build()?;
143 self.inner
144 .signer
145 .sign(&mut request)
146 .map_err(Error::Signing)?;
147 let response = self.inner.http.execute(request).await?;
148 let status = response.status();
149 let request_id = response
150 .headers()
151 .get("request-id")
152 .and_then(|v| v.to_str().ok())
153 .map(String::from);
154 let retry_after_header = response
155 .headers()
156 .get("retry-after")
157 .and_then(|v| v.to_str().ok())
158 .map(String::from);
159
160 let bytes = response.bytes().await?;
161
162 if !status.is_success() {
163 tracing::warn!(
164 status = status.as_u16(),
165 request_id = ?request_id,
166 "claude-api: error response"
167 );
168 return Err(Error::from_response(
169 http::StatusCode::from_u16(status.as_u16())
170 .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
171 request_id,
172 retry_after_header.as_deref(),
173 &bytes,
174 ));
175 }
176
177 Ok(serde_json::from_slice(&bytes)?)
178 }
179
180 pub(crate) async fn execute_with_retry<R, F>(
189 &self,
190 mut make_request: F,
191 per_request_betas: &[&str],
192 ) -> Result<R>
193 where
194 R: DeserializeOwned,
195 F: FnMut() -> reqwest::RequestBuilder,
196 {
197 let policy = &self.inner.retry;
198 let mut attempt: u32 = 1;
199 loop {
200 let builder = make_request();
201 match self.execute(builder, per_request_betas).await {
202 Ok(r) => return Ok(r),
203 Err(e) => {
204 if !e.is_retryable() || attempt >= policy.max_attempts {
205 return Err(e);
206 }
207 let backoff = policy.compute_backoff(attempt, e.retry_after());
208 tracing::warn!(
209 attempt,
210 retry_in_ms = u64::try_from(backoff.as_millis()).unwrap_or(u64::MAX),
211 request_id = ?e.request_id(),
212 status = ?e.status().map(|s| s.as_u16()),
213 "claude-api: retrying after error"
214 );
215 tokio::time::sleep(backoff).await;
216 attempt += 1;
217 }
218 }
219 }
220 }
221
222 pub(crate) async fn execute_streaming(
233 &self,
234 mut builder: reqwest::RequestBuilder,
235 per_request_betas: &[&str],
236 ) -> Result<reqwest::Response> {
237 if let Some(joined) = merge_betas(&self.inner.betas, per_request_betas) {
238 builder = builder.header("anthropic-beta", joined);
239 }
240
241 let mut request = builder.build()?;
242 self.inner
243 .signer
244 .sign(&mut request)
245 .map_err(Error::Signing)?;
246 let response = self.inner.http.execute(request).await?;
247 let status = response.status();
248
249 if !status.is_success() {
250 let request_id = response
251 .headers()
252 .get("request-id")
253 .and_then(|v| v.to_str().ok())
254 .map(String::from);
255 let retry_after_header = response
256 .headers()
257 .get("retry-after")
258 .and_then(|v| v.to_str().ok())
259 .map(String::from);
260 let bytes = response.bytes().await?;
261 tracing::warn!(
262 status = status.as_u16(),
263 request_id = ?request_id,
264 "claude-api: streaming connect failed"
265 );
266 return Err(Error::from_response(
267 http::StatusCode::from_u16(status.as_u16())
268 .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
269 request_id,
270 retry_after_header.as_deref(),
271 &bytes,
272 ));
273 }
274
275 Ok(response)
276 }
277
278 #[cfg(test)]
279 pub(crate) fn betas(&self) -> &[String] {
280 &self.inner.betas
281 }
282
283 pub(crate) fn render_dry_run(
288 &self,
289 mut builder: reqwest::RequestBuilder,
290 per_request_betas: &[&str],
291 ) -> Result<crate::dry_run::DryRun> {
292 if let Some(joined) = merge_betas(&self.inner.betas, per_request_betas) {
293 builder = builder.header("anthropic-beta", joined);
294 }
295 let mut req = builder.build()?;
296 self.inner.signer.sign(&mut req).map_err(Error::Signing)?;
299 let method = req.method().clone();
300 let url = req.url().to_string();
301 let mut headers = http::HeaderMap::new();
302 for (name, value) in req.headers() {
303 if let (Ok(name), Ok(value)) = (
306 http::HeaderName::from_bytes(name.as_ref()),
307 http::HeaderValue::from_bytes(value.as_bytes()),
308 ) {
309 headers.append(name, value);
310 }
311 }
312 let body = if let Some(body) = req.body() {
313 if let Some(bytes) = body.as_bytes() {
314 serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null)
315 } else {
316 serde_json::Value::Null
317 }
318 } else {
319 serde_json::Value::Null
320 };
321 Ok(crate::dry_run::DryRun {
322 method,
323 url,
324 headers,
325 body,
326 })
327 }
328}
329
330fn merge_betas(client_betas: &[String], per_request_betas: &[&str]) -> Option<String> {
337 let trimmed: Vec<&str> = client_betas
338 .iter()
339 .map(String::as_str)
340 .chain(per_request_betas.iter().copied())
341 .map(str::trim)
342 .filter(|s| !s.is_empty())
343 .collect();
344 if trimmed.is_empty() {
345 None
346 } else {
347 Some(trimmed.join(","))
348 }
349}
350
351#[derive(Default)]
353pub struct ClientBuilder {
354 api_key: Option<String>,
355 base_url: Option<String>,
356 user_agent: Option<String>,
357 timeout: Option<Duration>,
358 betas: Vec<String>,
359 retry: Option<RetryPolicy>,
360 http: Option<reqwest::Client>,
361 signer: Option<Arc<dyn RequestSigner>>,
362}
363
364impl std::fmt::Debug for ClientBuilder {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 f.debug_struct("ClientBuilder")
367 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
368 .field("base_url", &self.base_url)
369 .field("user_agent", &self.user_agent)
370 .field("timeout", &self.timeout)
371 .field("betas", &self.betas)
372 .field("retry", &self.retry)
373 .field("http", &self.http.is_some())
374 .field("signer", &self.signer.as_ref().map(|s| format!("{s:?}")))
375 .finish()
376 }
377}
378
379impl ClientBuilder {
380 #[must_use]
382 pub fn api_key(mut self, k: impl Into<String>) -> Self {
383 self.api_key = Some(k.into());
384 self
385 }
386
387 #[must_use]
390 pub fn base_url(mut self, url: impl Into<String>) -> Self {
391 self.base_url = Some(url.into());
392 self
393 }
394
395 #[must_use]
398 pub fn beta(mut self, header_value: impl Into<String>) -> Self {
399 self.betas.push(header_value.into());
400 self
401 }
402
403 #[must_use]
406 pub fn timeout(mut self, d: Duration) -> Self {
407 self.timeout = Some(d);
408 self
409 }
410
411 #[must_use]
413 pub fn retry(mut self, policy: RetryPolicy) -> Self {
414 self.retry = Some(policy);
415 self
416 }
417
418 #[must_use]
422 pub fn http_client(mut self, c: reqwest::Client) -> Self {
423 self.http = Some(c);
424 self
425 }
426
427 #[must_use]
429 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
430 self.user_agent = Some(ua.into());
431 self
432 }
433
434 #[must_use]
440 pub fn signer(mut self, signer: Arc<dyn RequestSigner>) -> Self {
441 self.signer = Some(signer);
442 self
443 }
444
445 pub fn build(self) -> Result<Client> {
448 let signer: Arc<dyn RequestSigner> = if let Some(s) = self.signer {
449 s
450 } else if let Some(key) = self.api_key {
451 Arc::new(ApiKeySigner::new(ApiKey::new(key)))
452 } else {
453 return Err(Error::InvalidConfig(
454 "either api_key or signer must be configured".into(),
455 ));
456 };
457
458 let http = if let Some(c) = self.http {
459 c
460 } else {
461 let mut builder = reqwest::Client::builder();
462 if let Some(t) = self.timeout {
463 builder = builder.timeout(t);
464 }
465 builder.build()?
466 };
467
468 let inner = Inner {
469 base_url: self
470 .base_url
471 .unwrap_or_else(|| crate::DEFAULT_BASE_URL.to_owned()),
472 http,
473 user_agent: self
474 .user_agent
475 .unwrap_or_else(|| crate::USER_AGENT.to_owned()),
476 betas: self.betas,
477 retry: self.retry.unwrap_or_default(),
478 signer,
479 };
480
481 Ok(Client {
482 inner: Arc::new(inner),
483 })
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use pretty_assertions::assert_eq;
491 use serde::Deserialize;
492 use serde_json::json;
493 use wiremock::matchers::{header, header_exists, method, path};
494 use wiremock::{Mock, MockServer, ResponseTemplate};
495
496 #[derive(Deserialize, Debug, PartialEq)]
497 struct Pong {
498 ok: bool,
499 }
500
501 fn client_for(mock: &MockServer) -> Client {
502 Client::builder()
503 .api_key("sk-ant-test-key")
504 .base_url(mock.uri())
505 .build()
506 .unwrap()
507 }
508
509 #[test]
510 fn build_requires_api_key() {
511 let err = Client::builder().build().unwrap_err();
512 assert!(matches!(err, Error::InvalidConfig(_)), "{err:?}");
513 }
514
515 #[cfg(feature = "bedrock")]
516 #[tokio::test]
517 async fn bedrock_signer_replaces_x_api_key_with_sigv4_headers() {
518 use crate::bedrock::{BedrockCredentials, BedrockSigner};
519 use wiremock::matchers::header_regex;
520 let mock = MockServer::start().await;
521 Mock::given(method("GET"))
522 .and(path("/v1/ping"))
523 .and(header_regex("authorization", "^AWS4-HMAC-SHA256 "))
525 .and(header_exists("x-amz-date"))
527 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
528 .mount(&mock)
529 .await;
530
531 let signer = std::sync::Arc::new(BedrockSigner::new(
532 BedrockCredentials::new("AKIDEXAMPLE", "secret"),
533 "us-east-1",
534 ));
535 let client = Client::builder()
536 .api_key("sk-ant-unused")
537 .base_url(mock.uri())
538 .signer(signer)
539 .build()
540 .unwrap();
541
542 let _: Pong = client
543 .execute(
544 client.request_builder(reqwest::Method::GET, "/v1/ping"),
545 &[],
546 )
547 .await
548 .unwrap();
549
550 let received = &mock.received_requests().await.unwrap()[0];
553 assert!(
554 received.headers.get("x-api-key").is_none(),
555 "x-api-key should not be set when a custom signer is installed",
556 );
557 }
558
559 #[test]
560 fn client_is_cheap_to_clone() {
561 let c1 = Client::new("sk-ant-x");
562 let c2 = c1.clone();
563 assert!(Arc::ptr_eq(&c1.inner, &c2.inner));
565 }
566
567 #[tokio::test]
568 async fn execute_sets_default_headers_and_decodes_response() {
569 let mock = MockServer::start().await;
570 Mock::given(method("GET"))
571 .and(path("/v1/ping"))
572 .and(header("x-api-key", "sk-ant-test-key"))
573 .and(header("anthropic-version", crate::ANTHROPIC_VERSION))
574 .and(header_exists("user-agent"))
575 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
576 .mount(&mock)
577 .await;
578
579 let client = client_for(&mock);
580 let resp: Pong = client
581 .execute(
582 client.request_builder(reqwest::Method::GET, "/v1/ping"),
583 &[],
584 )
585 .await
586 .unwrap();
587 assert_eq!(resp, Pong { ok: true });
588 }
589
590 #[tokio::test]
591 async fn beta_headers_from_builder_are_applied_and_comma_joined() {
592 let mock = MockServer::start().await;
593 Mock::given(method("GET"))
594 .and(path("/v1/ping"))
595 .and(header_exists("anthropic-beta"))
596 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
597 .mount(&mock)
598 .await;
599
600 let client = Client::builder()
601 .api_key("sk-ant-x")
602 .base_url(mock.uri())
603 .beta("feat-a")
604 .beta("feat-b")
605 .build()
606 .unwrap();
607
608 let _: Pong = client
609 .execute(
610 client.request_builder(reqwest::Method::GET, "/v1/ping"),
611 &[],
612 )
613 .await
614 .unwrap();
615
616 let req = &mock.received_requests().await.unwrap()[0];
617 let beta = req.headers.get("anthropic-beta").unwrap().to_str().unwrap();
618 assert_eq!(beta, "feat-a,feat-b");
619 }
620
621 #[tokio::test]
622 async fn per_request_betas_merge_with_builder_betas() {
623 let mock = MockServer::start().await;
624 Mock::given(method("GET"))
625 .and(path("/v1/ping"))
626 .and(header_exists("anthropic-beta"))
627 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
628 .mount(&mock)
629 .await;
630
631 let client = Client::builder()
632 .api_key("sk-ant-x")
633 .base_url(mock.uri())
634 .beta("client-level")
635 .build()
636 .unwrap();
637
638 let _: Pong = client
639 .execute(
640 client.request_builder(reqwest::Method::GET, "/v1/ping"),
641 &["per-req"],
642 )
643 .await
644 .unwrap();
645
646 let req = &mock.received_requests().await.unwrap()[0];
647 let beta = req.headers.get("anthropic-beta").unwrap().to_str().unwrap();
648 assert_eq!(beta, "client-level,per-req");
649 }
650
651 #[tokio::test]
652 async fn no_beta_header_when_none_configured() {
653 let mock = MockServer::start().await;
654 Mock::given(method("GET"))
658 .and(path("/v1/ping"))
659 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
660 .expect(1)
661 .mount(&mock)
662 .await;
663
664 let client = client_for(&mock);
665 let _: Pong = client
666 .execute(
667 client.request_builder(reqwest::Method::GET, "/v1/ping"),
668 &[],
669 )
670 .await
671 .unwrap();
672 }
673
674 #[tokio::test]
675 async fn error_response_maps_to_api_error_with_request_id_and_retry_after() {
676 let mock = MockServer::start().await;
677 Mock::given(method("GET"))
678 .and(path("/v1/ping"))
679 .respond_with(
680 ResponseTemplate::new(429)
681 .insert_header("request-id", "req_abc123")
682 .insert_header("retry-after", "8")
683 .set_body_json(json!({
684 "type": "error",
685 "error": {
686 "type": "rate_limit_error",
687 "message": "slow down please"
688 }
689 })),
690 )
691 .mount(&mock)
692 .await;
693
694 let client = client_for(&mock);
695 let err = client
696 .execute::<Pong>(
697 client.request_builder(reqwest::Method::GET, "/v1/ping"),
698 &[],
699 )
700 .await
701 .unwrap_err();
702
703 match err {
704 Error::Api {
705 status,
706 request_id,
707 kind,
708 message,
709 retry_after,
710 } => {
711 assert_eq!(status, http::StatusCode::TOO_MANY_REQUESTS);
712 assert_eq!(request_id.as_deref(), Some("req_abc123"));
713 assert_eq!(kind, crate::error::ApiErrorKind::RateLimitError);
714 assert_eq!(message, "slow down please");
715 assert_eq!(retry_after, Some(Duration::from_secs(8)));
716 }
717 other => panic!("expected Api, got {other:?}"),
718 }
719 }
720
721 #[tokio::test]
722 async fn non_json_error_body_falls_back_to_api_error() {
723 let mock = MockServer::start().await;
724 Mock::given(method("GET"))
725 .and(path("/v1/ping"))
726 .respond_with(ResponseTemplate::new(502).set_body_string("<html>bad gateway</html>"))
727 .mount(&mock)
728 .await;
729
730 let client = client_for(&mock);
731 let err = client
732 .execute::<Pong>(
733 client.request_builder(reqwest::Method::GET, "/v1/ping"),
734 &[],
735 )
736 .await
737 .unwrap_err();
738
739 match err {
740 Error::Api {
741 status,
742 message,
743 kind,
744 ..
745 } => {
746 assert_eq!(status, http::StatusCode::BAD_GATEWAY);
747 assert_eq!(kind, crate::error::ApiErrorKind::ApiError);
748 assert!(message.contains("bad gateway"), "{message}");
749 }
750 other => panic!("expected Api, got {other:?}"),
751 }
752 }
753
754 #[tokio::test]
755 async fn malformed_success_body_maps_to_decode_error() {
756 let mock = MockServer::start().await;
757 Mock::given(method("GET"))
758 .and(path("/v1/ping"))
759 .respond_with(ResponseTemplate::new(200).set_body_string("not json at all"))
760 .mount(&mock)
761 .await;
762
763 let client = client_for(&mock);
764 let err = client
765 .execute::<Pong>(
766 client.request_builder(reqwest::Method::GET, "/v1/ping"),
767 &[],
768 )
769 .await
770 .unwrap_err();
771
772 assert!(matches!(err, Error::Decode(_)), "{err:?}");
773 }
774
775 #[tokio::test]
776 async fn custom_user_agent_overrides_default() {
777 let mock = MockServer::start().await;
778 Mock::given(method("GET"))
779 .and(path("/v1/ping"))
780 .and(header("user-agent", "my-app/1.0"))
781 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
782 .mount(&mock)
783 .await;
784
785 let client = Client::builder()
786 .api_key("sk-ant-x")
787 .base_url(mock.uri())
788 .user_agent("my-app/1.0")
789 .build()
790 .unwrap();
791
792 let _: Pong = client
793 .execute(
794 client.request_builder(reqwest::Method::GET, "/v1/ping"),
795 &[],
796 )
797 .await
798 .unwrap();
799 }
800
801 fn fast_retry_policy() -> crate::retry::RetryPolicy {
802 crate::retry::RetryPolicy {
803 max_attempts: 3,
804 initial_backoff: Duration::from_millis(1),
805 max_backoff: Duration::from_millis(5),
806 jitter: crate::retry::Jitter::None,
807 respect_retry_after: false,
808 }
809 }
810
811 #[tokio::test]
812 async fn execute_with_retry_succeeds_after_transient_failure() {
813 let mock = MockServer::start().await;
814 Mock::given(method("GET"))
815 .and(path("/v1/ping"))
816 .respond_with(ResponseTemplate::new(503))
817 .up_to_n_times(1)
818 .mount(&mock)
819 .await;
820 Mock::given(method("GET"))
821 .and(path("/v1/ping"))
822 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
823 .mount(&mock)
824 .await;
825
826 let client = Client::builder()
827 .api_key("sk-ant-x")
828 .base_url(mock.uri())
829 .retry(fast_retry_policy())
830 .build()
831 .unwrap();
832
833 let resp: Pong = client
834 .execute_with_retry(
835 || client.request_builder(reqwest::Method::GET, "/v1/ping"),
836 &[],
837 )
838 .await
839 .unwrap();
840 assert!(resp.ok);
841 assert_eq!(mock.received_requests().await.unwrap().len(), 2);
843 }
844
845 #[tokio::test]
846 async fn execute_with_retry_gives_up_after_max_attempts() {
847 let mock = MockServer::start().await;
848 Mock::given(method("GET"))
849 .and(path("/v1/ping"))
850 .respond_with(ResponseTemplate::new(503))
851 .mount(&mock)
852 .await;
853
854 let client = Client::builder()
855 .api_key("sk-ant-x")
856 .base_url(mock.uri())
857 .retry(fast_retry_policy())
858 .build()
859 .unwrap();
860
861 let err = client
862 .execute_with_retry::<Pong, _>(
863 || client.request_builder(reqwest::Method::GET, "/v1/ping"),
864 &[],
865 )
866 .await
867 .unwrap_err();
868 assert_eq!(err.status(), Some(http::StatusCode::SERVICE_UNAVAILABLE));
869 assert_eq!(mock.received_requests().await.unwrap().len(), 3);
871 }
872
873 #[tokio::test]
874 async fn execute_with_retry_does_not_retry_non_retryable_errors() {
875 let mock = MockServer::start().await;
876 Mock::given(method("GET"))
877 .and(path("/v1/ping"))
878 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
879 "type": "error",
880 "error": {"type": "invalid_request_error", "message": "bad input"}
881 })))
882 .expect(1)
883 .mount(&mock)
884 .await;
885
886 let client = Client::builder()
887 .api_key("sk-ant-x")
888 .base_url(mock.uri())
889 .retry(fast_retry_policy())
890 .build()
891 .unwrap();
892
893 let err = client
894 .execute_with_retry::<Pong, _>(
895 || client.request_builder(reqwest::Method::GET, "/v1/ping"),
896 &[],
897 )
898 .await
899 .unwrap_err();
900 assert_eq!(err.status(), Some(http::StatusCode::BAD_REQUEST));
901 assert_eq!(mock.received_requests().await.unwrap().len(), 1);
902 }
903
904 #[tokio::test]
905 async fn execute_with_retry_honors_retry_after_header() {
906 let mock = MockServer::start().await;
907 Mock::given(method("GET"))
908 .and(path("/v1/ping"))
909 .respond_with(
910 ResponseTemplate::new(429)
911 .insert_header("retry-after", "0")
912 .set_body_json(json!({
913 "type": "error",
914 "error": {"type": "rate_limit_error", "message": "slow down"}
915 })),
916 )
917 .up_to_n_times(1)
918 .mount(&mock)
919 .await;
920 Mock::given(method("GET"))
921 .and(path("/v1/ping"))
922 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
923 .mount(&mock)
924 .await;
925
926 let client = Client::builder()
927 .api_key("sk-ant-x")
928 .base_url(mock.uri())
929 .retry(crate::retry::RetryPolicy {
930 respect_retry_after: true,
931 ..fast_retry_policy()
932 })
933 .build()
934 .unwrap();
935
936 let resp: Pong = client
937 .execute_with_retry(
938 || client.request_builder(reqwest::Method::GET, "/v1/ping"),
939 &[],
940 )
941 .await
942 .unwrap();
943 assert!(resp.ok);
944 }
945
946 #[test]
947 fn builder_collects_betas_in_order() {
948 let client = Client::builder()
949 .api_key("sk-ant-x")
950 .beta("a")
951 .beta("b")
952 .beta("c")
953 .build()
954 .unwrap();
955 assert_eq!(
956 client.betas(),
957 &["a".to_owned(), "b".to_owned(), "c".to_owned()]
958 );
959 }
960
961 #[test]
962 fn merge_betas_returns_none_when_all_inputs_empty_or_whitespace() {
963 assert_eq!(merge_betas(&[], &[]), None);
964 assert_eq!(
965 merge_betas(&[String::new(), " ".into()], &["", " "]),
966 None
967 );
968 }
969
970 #[test]
971 fn merge_betas_filters_empties_and_trims() {
972 let client_betas = vec![" feat-a ".to_owned(), String::new(), "feat-b".to_owned()];
973 let per_request = ["", "feat-c\n", " "];
974 assert_eq!(
975 merge_betas(&client_betas, &per_request).as_deref(),
976 Some("feat-a,feat-b,feat-c")
977 );
978 }
979
980 #[test]
981 fn merge_betas_preserves_order_client_then_per_request() {
982 assert_eq!(
983 merge_betas(&["x".into(), "y".into()], &["z", "w"]).as_deref(),
984 Some("x,y,z,w")
985 );
986 }
987
988 #[test]
989 fn merge_betas_keeps_duplicates_intact() {
990 assert_eq!(
992 merge_betas(&["foo".into()], &["foo"]).as_deref(),
993 Some("foo,foo")
994 );
995 }
996
997 #[tokio::test]
998 async fn beta_header_omits_when_only_whitespace_supplied() {
999 let mock = MockServer::start().await;
1000 Mock::given(method("GET"))
1001 .and(path("/v1/ping"))
1002 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
1003 .mount(&mock)
1004 .await;
1005
1006 let client = Client::builder()
1007 .api_key("sk-ant-x")
1008 .base_url(mock.uri())
1009 .beta(" ")
1010 .beta("")
1011 .build()
1012 .unwrap();
1013
1014 let _: Pong = client
1015 .execute(
1016 client.request_builder(reqwest::Method::GET, "/v1/ping"),
1017 &[" "],
1018 )
1019 .await
1020 .unwrap();
1021
1022 let req = &mock.received_requests().await.unwrap()[0];
1023 assert!(
1024 req.headers.get("anthropic-beta").is_none(),
1025 "expected no anthropic-beta header when all values are whitespace"
1026 );
1027 }
1028}