Skip to main content

claude_api/
client.rs

1//! HTTP client and builder.
2//!
3//! [`Client`] is the entry point to the SDK. It is cheap to [`Clone`] (an
4//! `Arc<Inner>` under the hood) and `Send + Sync`, so a single instance can
5//! be shared across tasks.
6
7#![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/// HTTP client for the Anthropic API.
19#[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    /// Construct a [`Client`] with default settings and the given API key.
36    ///
37    /// # Panics
38    ///
39    /// Panics if reqwest fails to build its default HTTP client (extremely
40    /// unusual; would indicate a broken TLS stack). Use [`Client::builder`]
41    /// for a fallible alternative.
42    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    /// Begin configuring a [`Client`].
50    pub fn builder() -> ClientBuilder {
51        ClientBuilder::default()
52    }
53
54    /// Namespace handle for the Messages API.
55    pub fn messages(&self) -> crate::messages::Messages<'_> {
56        crate::messages::Messages::new(self)
57    }
58
59    /// Namespace handle for the Models API.
60    pub fn models(&self) -> crate::models::Models<'_> {
61        crate::models::Models::new(self)
62    }
63
64    /// Namespace handle for the Batches API.
65    pub fn batches(&self) -> crate::batches::Batches<'_> {
66        crate::batches::Batches::new(self)
67    }
68
69    /// Namespace handle for the Files API (beta).
70    pub fn files(&self) -> crate::files::Files<'_> {
71        crate::files::Files::new(self)
72    }
73
74    /// Namespace handle for the Managed Agents API (preview).
75    ///
76    /// Gated on the `managed-agents-preview` feature.
77    #[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    /// Namespace handle for the Admin API. Requires an admin API key.
84    ///
85    /// Gated on the `admin` feature.
86    #[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    /// Namespace handle for the Skills API (beta).
93    ///
94    /// Gated on the `skills` feature.
95    #[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    /// Namespace handle for the User Profiles API (beta).
102    ///
103    /// Gated on the `user-profiles` feature.
104    #[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    /// Build a [`reqwest::RequestBuilder`] preloaded with the version
111    /// and user-agent headers. Auth headers are added later by the
112    /// configured [`RequestSigner`](crate::auth::RequestSigner). Endpoints
113    /// add their body and any per-request beta headers, then call
114    /// [`Self::execute`].
115    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    /// Send a prepared request, merge in beta headers, and decode the response.
129    ///
130    /// Errors from the API (any non-2xx status) are mapped to [`Error::Api`]
131    /// with `request-id` and `Retry-After` populated when the server sent
132    /// them. The retry loop ([`Self::execute_with_retry`]) wraps this method.
133    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    /// Send a request with retries.
181    ///
182    /// `make_request` is called once per attempt to produce a fresh
183    /// [`reqwest::RequestBuilder`]. Retries are gated by
184    /// [`Error::is_retryable`] and spaced according to
185    /// [`RetryPolicy::compute_backoff`]. Streaming endpoints intentionally do
186    /// *not* go through this path -- a mid-stream retry would silently drop
187    /// content.
188    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    /// Send a request expected to return a streaming body.
223    ///
224    /// Returns the raw [`reqwest::Response`] on success so the caller can
225    /// wrap its body in an SSE parser, JSONL parser, or other line-oriented
226    /// reader. Non-2xx responses are mapped to [`Error::Api`] (with
227    /// `request-id` and `Retry-After`) just like [`Self::execute`]; the
228    /// body is consumed in that case.
229    ///
230    /// Streaming is *not* retried -- once the server starts emitting events,
231    /// retrying mid-stream would silently drop content.
232    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    /// Materialize a request without sending it, for use by namespace-level
284    /// `dry_run` helpers. Mirrors the header logic in
285    /// [`Self::execute`]/[`Self::execute_streaming`] so the rendered
286    /// preview matches what would actually be transmitted.
287    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        // Run the signer through dry_run too so the rendered preview
297        // matches the wire bytes the live client would actually send.
298        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            // Convert reqwest::header::HeaderName/Value (re-exports of http
304            // types) into the http crate's owned types.
305            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
330/// Merge client-level and per-request beta values into a single
331/// comma-joined header value.
332///
333/// Order is preserved: client-level betas first, in insertion order, then
334/// per-request betas. Empty or whitespace-only entries are dropped, and
335/// each entry is trimmed. Returns `None` if no entries remain.
336fn 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/// Builder for [`Client`].
352#[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    /// API key; required.
381    #[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    /// Override the base URL. Useful for proxies and `wiremock`-based tests.
388    /// Defaults to `https://api.anthropic.com`.
389    #[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    /// Append an `anthropic-beta` value. May be called multiple times; values
396    /// are comma-joined per Anthropic convention.
397    #[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    /// Per-request timeout applied to the underlying reqwest client.
404    /// Ignored if a custom HTTP client is supplied via [`Self::http_client`].
405    #[must_use]
406    pub fn timeout(mut self, d: Duration) -> Self {
407        self.timeout = Some(d);
408        self
409    }
410
411    /// Override the default retry policy.
412    #[must_use]
413    pub fn retry(mut self, policy: RetryPolicy) -> Self {
414        self.retry = Some(policy);
415        self
416    }
417
418    /// Supply your own [`reqwest::Client`]. Lets callers reuse a connection
419    /// pool, install custom middleware, or configure proxy / TLS settings
420    /// outside the SDK.
421    #[must_use]
422    pub fn http_client(mut self, c: reqwest::Client) -> Self {
423        self.http = Some(c);
424        self
425    }
426
427    /// Override the `User-Agent` header. Defaults to `claude-api-rs/<version>`.
428    #[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    /// Install a custom [`RequestSigner`]. If unset, the builder
435    /// defaults to [`ApiKeySigner`] from the configured `api_key`.
436    /// Setting both is allowed: the explicit signer takes precedence
437    /// (useful for tests that want a no-op signer with an unused
438    /// placeholder key).
439    #[must_use]
440    pub fn signer(mut self, signer: Arc<dyn RequestSigner>) -> Self {
441        self.signer = Some(signer);
442        self
443    }
444
445    /// Construct the [`Client`]. Returns [`Error::InvalidConfig`] if
446    /// neither an `api_key` nor a custom `signer` was provided.
447    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            // sigv4 always emits an Authorization header beginning with the algorithm prefix.
524            .and(header_regex("authorization", "^AWS4-HMAC-SHA256 "))
525            // x-amz-date is the canonical timestamp header.
526            .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        // Wiremock would 404 if the signer hadn't run; explicit
551        // negative check on the live captured request:
552        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        // Both clones point at the same Arc<Inner>.
564        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        // We can't easily assert "header NOT present" with wiremock matchers,
655        // but if the request fails to match our (no-beta) mock, the call would
656        // 404 and the assert below would fire.
657        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        // Two requests total: one 503 retry + one success.
842        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        // max_attempts = 3 -> 3 total requests.
870        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        // Dedup is intentionally NOT performed; users manage their own set.
991        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}