Skip to main content

claude_api/blocking/
client.rs

1//! Synchronous HTTP client.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde::de::DeserializeOwned;
7
8use crate::auth::ApiKey;
9use crate::error::{Error, Result};
10use crate::retry::RetryPolicy;
11
12/// Synchronous HTTP client for the Anthropic API.
13///
14/// Counterpart to [`crate::Client`]; same builder shape, same retry policy,
15/// same error mapping. Cheap to [`Clone`] (an `Arc<Inner>` under the hood).
16#[derive(Debug, Clone)]
17pub struct Client {
18    inner: Arc<Inner>,
19}
20
21#[derive(Debug)]
22struct Inner {
23    api_key: ApiKey,
24    base_url: String,
25    http: reqwest::blocking::Client,
26    user_agent: String,
27    betas: Vec<String>,
28    retry: RetryPolicy,
29}
30
31impl Client {
32    /// Construct a client with default settings and the given API key.
33    ///
34    /// # Panics
35    ///
36    /// Panics if reqwest fails to build its default blocking HTTP client
37    /// (extremely unusual; would indicate a broken TLS stack). Use
38    /// [`Client::builder`] for a fallible alternative.
39    pub fn new(api_key: impl Into<String>) -> Self {
40        Self::builder()
41            .api_key(api_key)
42            .build()
43            .expect("default builder should succeed when an api key is provided")
44    }
45
46    /// Begin configuring a [`Client`].
47    pub fn builder() -> ClientBuilder {
48        ClientBuilder::default()
49    }
50
51    /// Namespace handle for the Messages API.
52    pub fn messages(&self) -> super::Messages<'_> {
53        super::Messages::new(self)
54    }
55
56    /// Namespace handle for the Models API.
57    pub fn models(&self) -> super::Models<'_> {
58        super::Models::new(self)
59    }
60
61    /// Build a [`reqwest::blocking::RequestBuilder`] preloaded with the
62    /// per-request authentication, version, and user-agent headers.
63    pub(crate) fn request_builder(
64        &self,
65        method: reqwest::Method,
66        path: &str,
67    ) -> reqwest::blocking::RequestBuilder {
68        let url = format!("{}{}", self.inner.base_url, path);
69        self.inner
70            .http
71            .request(method, url)
72            .header("x-api-key", self.inner.api_key.as_str())
73            .header("anthropic-version", crate::ANTHROPIC_VERSION)
74            .header(reqwest::header::USER_AGENT, &self.inner.user_agent)
75    }
76
77    /// Send a prepared request synchronously. Mirrors the async
78    /// [`crate::Client::execute`] -- same header merging, same error
79    /// mapping. No retries; use [`Self::execute_with_retry`] for that.
80    pub(crate) fn execute<R: DeserializeOwned>(
81        &self,
82        mut builder: reqwest::blocking::RequestBuilder,
83        per_request_betas: &[&str],
84    ) -> Result<R> {
85        if let Some(joined) = merge_betas(&self.inner.betas, per_request_betas) {
86            builder = builder.header("anthropic-beta", joined);
87        }
88
89        let response = builder.send()?;
90        let status = response.status();
91        let request_id = response
92            .headers()
93            .get("request-id")
94            .and_then(|v| v.to_str().ok())
95            .map(String::from);
96        let retry_after_header = response
97            .headers()
98            .get("retry-after")
99            .and_then(|v| v.to_str().ok())
100            .map(String::from);
101
102        let bytes = response.bytes()?;
103
104        if !status.is_success() {
105            tracing::warn!(
106                status = status.as_u16(),
107                request_id = ?request_id,
108                "claude-api: error response"
109            );
110            return Err(Error::from_response(
111                http::StatusCode::from_u16(status.as_u16())
112                    .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
113                request_id,
114                retry_after_header.as_deref(),
115                &bytes,
116            ));
117        }
118
119        Ok(serde_json::from_slice(&bytes)?)
120    }
121
122    /// Send a request with retries. `make_request` is called once per
123    /// attempt to produce a fresh [`reqwest::blocking::RequestBuilder`].
124    pub(crate) fn execute_with_retry<R, F>(
125        &self,
126        mut make_request: F,
127        per_request_betas: &[&str],
128    ) -> Result<R>
129    where
130        R: DeserializeOwned,
131        F: FnMut() -> reqwest::blocking::RequestBuilder,
132    {
133        let policy = &self.inner.retry;
134        let mut attempt: u32 = 1;
135        loop {
136            let builder = make_request();
137            match self.execute(builder, per_request_betas) {
138                Ok(r) => return Ok(r),
139                Err(e) => {
140                    if !e.is_retryable() || attempt >= policy.max_attempts {
141                        return Err(e);
142                    }
143                    let backoff = policy.compute_backoff(attempt, e.retry_after());
144                    tracing::warn!(
145                        attempt,
146                        retry_in_ms = u64::try_from(backoff.as_millis()).unwrap_or(u64::MAX),
147                        request_id = ?e.request_id(),
148                        status = ?e.status().map(|s| s.as_u16()),
149                        "claude-api: retrying after error"
150                    );
151                    std::thread::sleep(backoff);
152                    attempt += 1;
153                }
154            }
155        }
156    }
157
158    #[cfg(test)]
159    pub(crate) fn betas(&self) -> &[String] {
160        &self.inner.betas
161    }
162}
163
164/// Merge client-level and per-request beta values into a single comma-joined
165/// header value. Mirrors the async-side helper; trims and skips empties,
166/// preserves order, no dedup.
167fn merge_betas(client_betas: &[String], per_request_betas: &[&str]) -> Option<String> {
168    let trimmed: Vec<&str> = client_betas
169        .iter()
170        .map(String::as_str)
171        .chain(per_request_betas.iter().copied())
172        .map(str::trim)
173        .filter(|s| !s.is_empty())
174        .collect();
175    if trimmed.is_empty() {
176        None
177    } else {
178        Some(trimmed.join(","))
179    }
180}
181
182/// Builder for [`Client`].
183#[derive(Debug, Default)]
184pub struct ClientBuilder {
185    api_key: Option<String>,
186    base_url: Option<String>,
187    user_agent: Option<String>,
188    timeout: Option<Duration>,
189    betas: Vec<String>,
190    retry: Option<RetryPolicy>,
191    http: Option<reqwest::blocking::Client>,
192}
193
194impl ClientBuilder {
195    /// API key; required.
196    #[must_use]
197    pub fn api_key(mut self, k: impl Into<String>) -> Self {
198        self.api_key = Some(k.into());
199        self
200    }
201
202    /// Override the base URL. Useful for proxies and `wiremock`-based tests.
203    #[must_use]
204    pub fn base_url(mut self, url: impl Into<String>) -> Self {
205        self.base_url = Some(url.into());
206        self
207    }
208
209    /// Append an `anthropic-beta` value. Repeatable; values are comma-joined.
210    #[must_use]
211    pub fn beta(mut self, header_value: impl Into<String>) -> Self {
212        self.betas.push(header_value.into());
213        self
214    }
215
216    /// Per-request timeout applied to the underlying reqwest blocking client.
217    /// Ignored if a custom HTTP client is supplied via [`Self::http_client`].
218    #[must_use]
219    pub fn timeout(mut self, d: Duration) -> Self {
220        self.timeout = Some(d);
221        self
222    }
223
224    /// Override the default retry policy.
225    #[must_use]
226    pub fn retry(mut self, policy: RetryPolicy) -> Self {
227        self.retry = Some(policy);
228        self
229    }
230
231    /// Supply your own [`reqwest::blocking::Client`].
232    #[must_use]
233    pub fn http_client(mut self, c: reqwest::blocking::Client) -> Self {
234        self.http = Some(c);
235        self
236    }
237
238    /// Override the `User-Agent` header. Defaults to `claude-api-rs/<version>`.
239    #[must_use]
240    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
241        self.user_agent = Some(ua.into());
242        self
243    }
244
245    /// Construct the [`Client`]. Returns [`Error::InvalidConfig`] if the API
246    /// key is missing.
247    pub fn build(self) -> Result<Client> {
248        let api_key = self
249            .api_key
250            .ok_or_else(|| Error::InvalidConfig("api_key is required".into()))?;
251
252        let http = if let Some(c) = self.http {
253            c
254        } else {
255            let mut builder = reqwest::blocking::Client::builder();
256            if let Some(t) = self.timeout {
257                builder = builder.timeout(t);
258            }
259            builder.build()?
260        };
261
262        let inner = Inner {
263            api_key: ApiKey::new(api_key),
264            base_url: self
265                .base_url
266                .unwrap_or_else(|| crate::DEFAULT_BASE_URL.to_owned()),
267            http,
268            user_agent: self
269                .user_agent
270                .unwrap_or_else(|| crate::USER_AGENT.to_owned()),
271            betas: self.betas,
272            retry: self.retry.unwrap_or_default(),
273        };
274
275        Ok(Client {
276            inner: Arc::new(inner),
277        })
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use pretty_assertions::assert_eq;
285
286    #[test]
287    fn build_requires_api_key() {
288        let err = Client::builder().build().unwrap_err();
289        assert!(matches!(err, Error::InvalidConfig(_)));
290    }
291
292    #[test]
293    fn client_is_cheap_to_clone() {
294        let c1 = Client::new("sk-ant-x");
295        let c2 = c1.clone();
296        assert!(Arc::ptr_eq(&c1.inner, &c2.inner));
297    }
298
299    #[test]
300    fn builder_collects_betas_in_order() {
301        let client = Client::builder()
302            .api_key("sk-ant-x")
303            .beta("a")
304            .beta("b")
305            .build()
306            .unwrap();
307        assert_eq!(client.betas(), &["a".to_owned(), "b".to_owned()]);
308    }
309
310    #[test]
311    fn merge_betas_filters_empties_and_trims() {
312        assert_eq!(
313            merge_betas(&["  a  ".into(), String::new()], &["", "b\n"]).as_deref(),
314            Some("a,b")
315        );
316        assert_eq!(merge_betas(&[], &[]), None);
317    }
318}