Skip to main content

truto/
client.rs

1//! The HTTP client: configuration, retries, and response handling.
2
3use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{
8    HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER,
9    USER_AGENT,
10};
11use reqwest::{Method, StatusCode};
12use serde::de::DeserializeOwned;
13use serde_json::{Map, Value};
14
15use crate::error::{ApiError, Error, ErrorBody};
16use crate::query::{self, Query};
17
18/// Default API base URL.
19pub const DEFAULT_BASE_URL: &str = "https://api.truto.one";
20/// Default per-attempt timeout.
21pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22/// Default number of retries after the first attempt.
23pub const DEFAULT_MAX_RETRIES: u32 = 3;
24/// Default wait after a 429 without a usable `Retry-After` header.
25pub const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(10);
26/// Statuses that are always retried (unless retries are disabled).
27pub const RETRY_STATUS_CODES: [u16; 7] = [408, 409, 425, 429, 502, 503, 504];
28
29const SDK_USER_AGENT: &str = concat!("truto-rust-sdk/", env!("CARGO_PKG_VERSION"));
30const BACKOFF_BASE: Duration = Duration::from_millis(500);
31const BACKOFF_CAP: Duration = Duration::from_secs(10);
32
33/// Called before each retry with `(retry_count, status)`. `retry_count` starts
34/// at 1; `status` is `None` when the retry follows a connection error.
35pub type RetryHook = Arc<dyn Fn(u32, Option<u16>) + Send + Sync>;
36/// Called on a 429 before waiting, with `(retry_after_seconds, retry_count)`.
37/// `retry_count` is the number of retries made so far.
38pub type RateLimitHook = Arc<dyn Fn(u64, u32) + Send + Sync>;
39
40/// Per-request overrides. Every request builder exposes the same settings as
41/// chainable methods (`.timeout(..)`, `.no_retry()`, ...); this struct is for
42/// reusing one set of overrides across calls via `.options(..)`.
43#[derive(Clone, Default)]
44pub struct RequestOptions {
45    /// Extra headers sent with the request.
46    pub headers: Vec<(String, String)>,
47    /// Per-attempt timeout (client default when `None`).
48    pub timeout: Option<Duration>,
49    /// Maximum retries (client default when `None`).
50    pub max_retries: Option<u32>,
51    /// Disable retries entirely.
52    pub no_retry: bool,
53    /// Statuses to retry in addition to [`RETRY_STATUS_CODES`].
54    pub retry_status_codes: Vec<u16>,
55    /// Retry hook (overrides the client's).
56    pub on_retry: Option<RetryHook>,
57    /// Rate-limit hook (overrides the client's).
58    pub on_rate_limited: Option<RateLimitHook>,
59}
60
61impl RequestOptions {
62    /// Empty overrides.
63    pub fn new() -> Self {
64        Self::default()
65    }
66}
67
68impl fmt::Debug for RequestOptions {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.debug_struct("RequestOptions")
71            .field("headers", &self.headers)
72            .field("timeout", &self.timeout)
73            .field("max_retries", &self.max_retries)
74            .field("no_retry", &self.no_retry)
75            .field("retry_status_codes", &self.retry_status_codes)
76            .field("on_retry", &self.on_retry.is_some())
77            .field("on_rate_limited", &self.on_rate_limited.is_some())
78            .finish()
79    }
80}
81
82struct Inner {
83    http: reqwest::Client,
84    token: String,
85    base_url: String,
86    timeout: Duration,
87    max_retries: u32,
88    default_retry_after: Duration,
89    on_retry: Option<RetryHook>,
90    on_rate_limited: Option<RateLimitHook>,
91}
92
93/// A Truto API client. Cheap to clone: clones share one connection pool.
94///
95/// ```no_run
96/// # async fn run() -> Result<(), truto::Error> {
97/// let client = truto::Client::new("my-api-token");
98/// let accounts = client.integrated_accounts().list().collect_all().await?;
99/// # Ok(()) }
100/// ```
101#[derive(Clone)]
102pub struct Client {
103    inner: Arc<Inner>,
104}
105
106impl fmt::Debug for Client {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.debug_struct("Client")
109            .field("base_url", &self.inner.base_url)
110            .field("timeout", &self.inner.timeout)
111            .field("max_retries", &self.inner.max_retries)
112            .finish_non_exhaustive()
113    }
114}
115
116impl Client {
117    /// A client with default settings.
118    ///
119    /// # Panics
120    ///
121    /// Panics if the TLS backend cannot be initialised (as `reqwest::Client::new` does).
122    /// Use [`Client::builder`] to handle that as an error instead.
123    pub fn new(token: impl Into<String>) -> Self {
124        Self::builder()
125            .token(token)
126            .build()
127            .expect("failed to build the truto client")
128    }
129
130    /// A builder for a configured client.
131    pub fn builder() -> ClientBuilder {
132        ClientBuilder::default()
133    }
134
135    /// The base URL requests are sent to.
136    pub fn base_url(&self) -> &str {
137        &self.inner.base_url
138    }
139
140    pub(crate) fn request<T>(&self, method: Method, path: impl Into<String>) -> crate::Request<T> {
141        crate::Request::new(self.clone(), method, path.into())
142    }
143
144    pub(crate) fn pager<T>(&self, path: impl Into<String>) -> crate::Pager<T> {
145        crate::Pager::new(self.clone(), path.into())
146    }
147
148    fn url(&self, path: &str, query: &Map<String, Value>) -> String {
149        let mut url = format!("{}/{}", self.inner.base_url, path.trim_start_matches('/'));
150        let qs = query::encode(query);
151        if !qs.is_empty() {
152            url.push('?');
153            url.push_str(&qs);
154        }
155        url
156    }
157
158    /// Send a request with retries and return the successful response.
159    pub(crate) async fn execute(
160        &self,
161        call: &Call,
162        opts: &RequestOptions,
163    ) -> Result<Response, Error> {
164        let inner = &self.inner;
165        let url = self.url(&call.path, &call.query);
166        let body = match &call.body {
167            Some(v) => {
168                Some(serde_json::to_vec(v).map_err(|e| Error::InvalidRequest(e.to_string()))?)
169            }
170            None => None,
171        };
172        let mut headers = HeaderMap::new();
173        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
174        for (k, v) in &opts.headers {
175            let name = HeaderName::from_bytes(k.as_bytes())
176                .map_err(|e| Error::InvalidRequest(format!("invalid header name {k:?}: {e}")))?;
177            let value = HeaderValue::from_str(v).map_err(|e| {
178                Error::InvalidRequest(format!("invalid header value for {k:?}: {e}"))
179            })?;
180            headers.insert(name, value);
181        }
182        let auth = HeaderValue::from_str(&format!("Bearer {}", inner.token))
183            .map_err(|e| Error::InvalidRequest(format!("invalid token: {e}")))?;
184        headers.insert(AUTHORIZATION, auth);
185        headers.insert(USER_AGENT, HeaderValue::from_static(SDK_USER_AGENT));
186        if body.is_some() {
187            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
188        }
189
190        let timeout = opts.timeout.unwrap_or(inner.timeout);
191        let max_retries = if opts.no_retry {
192            0
193        } else {
194            opts.max_retries.unwrap_or(inner.max_retries)
195        };
196        let on_retry = opts.on_retry.as_ref().or(inner.on_retry.as_ref());
197        let on_rate_limited = opts
198            .on_rate_limited
199            .as_ref()
200            .or(inner.on_rate_limited.as_ref());
201        let retryable =
202            |s: u16| RETRY_STATUS_CODES.contains(&s) || opts.retry_status_codes.contains(&s);
203
204        let mut retry_count: u32 = 0;
205        loop {
206            let mut req = inner
207                .http
208                .request(call.method.clone(), &url)
209                .headers(headers.clone());
210            if let Some(b) = &body {
211                req = req.body(b.clone());
212            }
213
214            let attempt = async {
215                let resp = req.send().await?;
216                let status = resp.status();
217                let headers = resp.headers().clone();
218                let body = resp.bytes().await?.to_vec();
219                Ok::<_, reqwest::Error>(Response {
220                    status,
221                    headers,
222                    body,
223                })
224            };
225
226            let resp = match tokio::time::timeout(timeout, attempt).await {
227                Err(_) => return Err(Error::Timeout),
228                Ok(Err(e)) if e.is_timeout() => return Err(Error::Timeout),
229                Ok(Err(e)) => {
230                    if e.is_connect() && retry_count < max_retries {
231                        tokio::time::sleep(backoff(retry_count)).await;
232                        retry_count += 1;
233                        if let Some(hook) = on_retry {
234                            hook(retry_count, None);
235                        }
236                        continue;
237                    }
238                    return Err(Error::Transport(e));
239                }
240                Ok(Ok(resp)) => resp,
241            };
242
243            if resp.status.is_success() {
244                return Ok(resp);
245            }
246
247            let status = resp.status.as_u16();
248            if retryable(status) && retry_count < max_retries {
249                if status == 429 {
250                    let wait = retry_after(&resp.headers).unwrap_or(inner.default_retry_after);
251                    if let Some(hook) = on_rate_limited {
252                        hook(wait.as_secs(), retry_count);
253                    }
254                    tokio::time::sleep(wait).await;
255                } else {
256                    tokio::time::sleep(backoff(retry_count)).await;
257                }
258                retry_count += 1;
259                if let Some(hook) = on_retry {
260                    hook(retry_count, Some(status));
261                }
262                continue;
263            }
264
265            return Err(api_error(resp));
266        }
267    }
268}
269
270/// Builder for [`Client`].
271#[derive(Default)]
272pub struct ClientBuilder {
273    token: Option<String>,
274    base_url: Option<String>,
275    timeout: Option<Duration>,
276    max_retries: Option<u32>,
277    default_retry_after: Option<Duration>,
278    http: Option<reqwest::Client>,
279    on_retry: Option<RetryHook>,
280    on_rate_limited: Option<RateLimitHook>,
281}
282
283impl fmt::Debug for ClientBuilder {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        f.debug_struct("ClientBuilder")
286            .field("base_url", &self.base_url)
287            .field("timeout", &self.timeout)
288            .field("max_retries", &self.max_retries)
289            .field("default_retry_after", &self.default_retry_after)
290            .finish_non_exhaustive()
291    }
292}
293
294impl ClientBuilder {
295    /// The API token (required).
296    pub fn token(mut self, token: impl Into<String>) -> Self {
297        self.token = Some(token.into());
298        self
299    }
300
301    /// The API base URL (default `https://api.truto.one`).
302    pub fn base_url(mut self, url: impl Into<String>) -> Self {
303        self.base_url = Some(url.into());
304        self
305    }
306
307    /// Per-attempt timeout (default 30s).
308    pub fn timeout(mut self, timeout: Duration) -> Self {
309        self.timeout = Some(timeout);
310        self
311    }
312
313    /// Maximum retries after the first attempt (default 3).
314    pub fn max_retries(mut self, n: u32) -> Self {
315        self.max_retries = Some(n);
316        self
317    }
318
319    /// Wait after a 429 that has no usable `Retry-After` header (default 10s).
320    pub fn default_retry_after(mut self, wait: Duration) -> Self {
321        self.default_retry_after = Some(wait);
322        self
323    }
324
325    /// Use your own `reqwest::Client` (proxies, custom TLS, ...).
326    pub fn http_client(mut self, http: reqwest::Client) -> Self {
327        self.http = Some(http);
328        self
329    }
330
331    /// Called before every retry with `(retry_count, status)`.
332    pub fn on_retry(mut self, hook: impl Fn(u32, Option<u16>) + Send + Sync + 'static) -> Self {
333        self.on_retry = Some(Arc::new(hook));
334        self
335    }
336
337    /// Called on a 429 before waiting, with `(retry_after_seconds, retry_count)`.
338    pub fn on_rate_limited(mut self, hook: impl Fn(u64, u32) + Send + Sync + 'static) -> Self {
339        self.on_rate_limited = Some(Arc::new(hook));
340        self
341    }
342
343    /// Build the client. Fails if no token was set or the HTTP client cannot be created.
344    pub fn build(self) -> Result<Client, Error> {
345        let token = self
346            .token
347            .filter(|t| !t.is_empty())
348            .ok_or_else(|| Error::InvalidRequest("an API token is required".into()))?;
349        let http = match self.http {
350            Some(http) => http,
351            None => reqwest::Client::builder()
352                .build()
353                .map_err(Error::Transport)?,
354        };
355        let base_url = self
356            .base_url
357            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
358            .trim_end_matches('/')
359            .to_string();
360        Ok(Client {
361            inner: Arc::new(Inner {
362                http,
363                token,
364                base_url,
365                timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
366                max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
367                default_retry_after: self.default_retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
368                on_retry: self.on_retry,
369                on_rate_limited: self.on_rate_limited,
370            }),
371        })
372    }
373}
374
375/// One HTTP call, before retries.
376#[derive(Debug, Clone)]
377pub(crate) struct Call {
378    pub method: Method,
379    pub path: String,
380    pub query: Map<String, Value>,
381    pub body: Option<Value>,
382}
383
384impl Call {
385    pub(crate) fn new(method: Method, path: String, query: &Query, body: Option<Value>) -> Self {
386        Self {
387            method,
388            path,
389            query: query.as_map().clone(),
390            body,
391        }
392    }
393}
394
395/// A successful response.
396pub(crate) struct Response {
397    pub status: StatusCode,
398    pub headers: HeaderMap,
399    pub body: Vec<u8>,
400}
401
402impl Response {
403    fn is_empty(&self) -> bool {
404        self.status == StatusCode::NO_CONTENT || self.body.iter().all(u8::is_ascii_whitespace)
405    }
406
407    fn is_json(&self) -> bool {
408        content_type(&self.headers).is_some_and(|c| c.contains("json"))
409    }
410
411    /// Decode the body into `T` (see the contract's response rules).
412    pub(crate) fn decode<T: DeserializeOwned>(&self) -> Result<T, Error> {
413        if self.is_empty() {
414            return decode_empty();
415        }
416        if !self.is_json() {
417            return Err(Error::decode(
418                format!(
419                    "expected a JSON response, got content-type {:?}",
420                    content_type(&self.headers).unwrap_or("")
421                ),
422                None,
423            ));
424        }
425        serde_json::from_slice(&self.body).map_err(|e| Error::decode(e.to_string(), Some(e)))
426    }
427}
428
429/// Decode an empty response: `null` for types that accept it (unit, `Option`,
430/// `Value`), otherwise `{}` (records and models).
431pub(crate) fn decode_empty<T: DeserializeOwned>() -> Result<T, Error> {
432    serde_json::from_value(Value::Null)
433        .or_else(|_| serde_json::from_value(Value::Object(Map::new())))
434        .map_err(|e| Error::decode(format!("empty response: {e}"), Some(e)))
435}
436
437pub(crate) fn content_type(headers: &HeaderMap) -> Option<&str> {
438    headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok())
439}
440
441fn api_error(resp: Response) -> Error {
442    let text = String::from_utf8_lossy(&resp.body).into_owned();
443    let body = if resp.is_json() {
444        match serde_json::from_slice(&resp.body) {
445            Ok(v) => ErrorBody::Json(v),
446            Err(_) => ErrorBody::Text(text),
447        }
448    } else {
449        ErrorBody::Text(text)
450    };
451    Error::Api(Box::new(ApiError {
452        status: resp.status.as_u16(),
453        status_text: resp.status.canonical_reason().unwrap_or("").to_string(),
454        body,
455        headers: resp.headers,
456    }))
457}
458
459fn retry_after(headers: &HeaderMap) -> Option<Duration> {
460    let v = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
461    v.parse::<u64>().ok().map(Duration::from_secs)
462}
463
464/// Exponential backoff with full jitter: uniform in `[0, min(cap, base * 2^n)]`.
465fn backoff(retry_count: u32) -> Duration {
466    let ceiling = BACKOFF_BASE
467        .saturating_mul(1u32 << retry_count.min(16))
468        .min(BACKOFF_CAP);
469    let millis = ceiling.as_millis() as u64;
470    Duration::from_millis(random_u64() % (millis + 1))
471}
472
473fn random_u64() -> u64 {
474    use std::collections::hash_map::RandomState;
475    use std::hash::{BuildHasher, Hasher};
476    let mut h = RandomState::new().build_hasher();
477    h.write_u128(
478        std::time::SystemTime::now()
479            .duration_since(std::time::UNIX_EPOCH)
480            .map(|d| d.as_nanos())
481            .unwrap_or(0),
482    );
483    h.finish()
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn backoff_stays_within_bounds() {
492        for n in 0..20 {
493            let d = backoff(n);
494            assert!(d <= BACKOFF_CAP);
495            if n == 0 {
496                assert!(d <= BACKOFF_BASE);
497            }
498        }
499    }
500
501    #[test]
502    fn builder_requires_token_and_trims_base_url() {
503        assert!(Client::builder().build().is_err());
504        let c = Client::builder()
505            .token("t")
506            .base_url("http://localhost:1/")
507            .build()
508            .unwrap();
509        assert_eq!(c.base_url(), "http://localhost:1");
510    }
511}