Skip to main content

datamaxi/
api.rs

1//! ## Observability
2//!
3//! Two independent, additive, opt-in hooks:
4//!
5//! - **`tracing` feature** — instruments [`Client::get`] / [`sync::Client::get`]
6//!   with a span (`method`, `endpoint`, `attempt`, `status`) and debug events on
7//!   each retry (backoff delay, transient status/error). Off by default and
8//!   compiles away entirely (no `tracing` dependency pulled in) when disabled.
9//!   The API key is never recorded — [`Client`]'s `Debug` impl already redacts
10//!   it, and no span/event field ever carries it.
11//! - **Custom HTTP client** — [`ClientBuilder::http_client`] /
12//!   [`sync::ClientBuilder::http_client`] let callers supply their own
13//!   pre-built `reqwest::Client`, e.g. wrapped with `reqwest-middleware` for
14//!   custom auth, metrics, or logging middleware. When omitted, the client
15//!   falls back to the built-in defaults (`User-Agent`, unbounded idle pool,
16//!   the configured timeout).
17//!
18//! ## Pagination
19//!
20//! [`Client::paginate`] / [`sync::Client::paginate`] auto-paginate any
21//! [`Paginated`] response envelope, whether or not it reports a `total` (see
22//! [`Paginated::total`]). The async [`Paginator`] is a plain `next_page()`
23//! cursor by default; enabling the opt-in **`stream` feature** additionally
24//! implements
25//! [`Stream`](https://docs.rs/futures-core/latest/futures_core/stream/trait.Stream.html)
26//! for it, so it composes with `futures`/`StreamExt` combinators and
27//! `.next().await`. Off by default and compiles away entirely (no
28//! `futures-core` dependency pulled in) when disabled. The blocking
29//! [`sync::Paginator`] already implements [`Iterator`] unconditionally.
30
31use reqwest::StatusCode;
32use serde::de::DeserializeOwned;
33use std::collections::BTreeMap;
34use std::marker::PhantomData;
35use std::sync::Arc;
36use std::time::{Duration, SystemTime};
37use thiserror::Error;
38
39// Host only: the generated endpoint paths are fully qualified and already
40// carry the `/api/v1` prefix, so the base URL must not repeat it (otherwise
41// requests double-prefix to `/api/v1/api/v1/...`). Matches the documented
42// default in the crate docs.
43const BASE_URL: &str = "https://api.datamaxiplus.com";
44
45/// Default per-request timeout, matching the Python SDK's default.
46const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
47
48/// Environment variable consulted for the API key when one is not passed explicitly.
49const API_KEY_ENV: &str = "DATAMAXI_API_KEY";
50
51/// Default number of retries on transient failures. Zero keeps the client's
52/// behavior unchanged unless retries are explicitly opted into via
53/// [`ClientBuilder::max_retries`].
54const DEFAULT_MAX_RETRIES: u32 = 0;
55
56/// Default base delay for exponential backoff between retries.
57const DEFAULT_RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
58
59/// Hard cap on any single backoff/`Retry-After` wait, so a huge exponent or an
60/// abusive `Retry-After` header can never stall a request indefinitely.
61const RETRY_MAX_DELAY: Duration = Duration::from_secs(30);
62
63/// Hard cap, in bytes, on how much of a `400`/`500` error body is read and
64/// surfaced via [`Error::BadRequest`] / [`Error::InternalServerError`].
65/// Shared by the async streaming reader ([`read_body_capped`]), the blocking
66/// `Read`-based reader, and [`truncate_body`], so the cap can never drift
67/// between call sites.
68const MAX_ERROR_BODY_BYTES: usize = 1000;
69
70/// Retry/backoff policy shared by the async and blocking clients.
71///
72/// Transient conditions — request timeouts, connection errors, `429 Too Many
73/// Requests`, and `5xx` server errors — are retried up to `max_retries` times
74/// with exponential backoff (`base_delay * 2^attempt`, capped at
75/// [`RETRY_MAX_DELAY`]) plus full jitter (see [`apply_jitter`]), so many
76/// clients retrying at once don't thundering-herd the server in lockstep. A
77/// `429` response honors its `Retry-After` header when present (either the
78/// delay-seconds or the HTTP-date form; see [`parse_retry_after`]) — that's an
79/// explicit server-suggested delay, so it is used as-is, without jitter. Fatal
80/// statuses (`400`/`401`/`403`/`404`, and every other `4xx`) are never retried.
81#[derive(Debug, Clone)]
82struct RetryConfig {
83    max_retries: u32,
84    base_delay: Duration,
85}
86
87impl Default for RetryConfig {
88    fn default() -> Self {
89        RetryConfig {
90            max_retries: DEFAULT_MAX_RETRIES,
91            base_delay: DEFAULT_RETRY_BASE_DELAY,
92        }
93    }
94}
95
96/// Whether a response status is transient and worth retrying: `429` or any
97/// `5xx`. All other statuses (including the fatal `400`/`401`/`403`/`404`) are
98/// terminal.
99fn is_retryable_status(status: StatusCode) -> bool {
100    status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
101}
102
103/// Whether a transport-level error is transient: a timeout or a failure to
104/// connect. Other errors (e.g. body decode) are terminal.
105fn is_retryable_error(error: &reqwest::Error) -> bool {
106    error.is_timeout() || error.is_connect()
107}
108
109/// Exponential backoff for the given zero-based `attempt`: `base * 2^attempt`,
110/// saturating and capped at [`RETRY_MAX_DELAY`].
111///
112/// This is the pure upper bound only — no jitter — so it stays exactly
113/// testable. Retry call sites should use [`jittered_backoff_delay`], which
114/// applies full jitter on top of this value.
115fn backoff_delay(config: &RetryConfig, attempt: u32) -> Duration {
116    let factor = 1u32.checked_shl(attempt).unwrap_or(u32::MAX);
117    config
118        .base_delay
119        .checked_mul(factor)
120        .unwrap_or(RETRY_MAX_DELAY)
121        .min(RETRY_MAX_DELAY)
122}
123
124/// Full-jitter [`backoff_delay`]: a uniformly random duration in
125/// `[0, backoff_delay(config, attempt)]`. Chosen over equal-jitter because it
126/// spreads retries over the widest possible range, which minimizes
127/// thundering-herd risk the most (see the "Exponential Backoff and Jitter"
128/// AWS Architecture Blog post, which found full jitter performs best of the
129/// strategies it benchmarks). Still bounded by [`RETRY_MAX_DELAY`], since the
130/// underlying `backoff_delay` upper bound already is.
131fn jittered_backoff_delay(config: &RetryConfig, attempt: u32) -> Duration {
132    apply_jitter(backoff_delay(config, attempt))
133}
134
135/// Picks a uniformly random duration in `[0, upper]` using the process-wide
136/// `fastrand` source. `upper` is expected to already be capped (see
137/// [`backoff_delay`]), so the result is too.
138fn apply_jitter(upper: Duration) -> Duration {
139    jitter_in_range(upper, fastrand::u64)
140}
141
142/// Same computation as [`apply_jitter`], with the random-number source
143/// injected so tests can assert exact, deterministic output instead of
144/// depending on real randomness.
145fn jitter_in_range(
146    upper: Duration,
147    random_u64: impl FnOnce(std::ops::RangeInclusive<u64>) -> u64,
148) -> Duration {
149    let upper_millis = u64::try_from(upper.as_millis()).unwrap_or(u64::MAX);
150    if upper_millis == 0 {
151        return Duration::ZERO;
152    }
153    Duration::from_millis(random_u64(0..=upper_millis))
154}
155
156/// Parse a `Retry-After` header into a [`Duration`], handling both forms
157/// defined in RFC 9110 §10.2.3:
158///
159/// - **delay-seconds** — a non-negative integer number of seconds, used as-is.
160/// - **HTTP-date** — an absolute date; the delay is `date - now`, clamped to
161///   zero if the date is already in the past (so a stale window never yields a
162///   negative or wildly large value). "Now" is read from the system clock.
163///
164/// A missing, non-ASCII, or otherwise unparseable header yields `None` rather
165/// than panicking.
166///
167/// The returned value is the raw parsed duration, uncapped. Internal retry
168/// sleeps must apply their own [`RETRY_MAX_DELAY`] cap at the call site (see
169/// [`retry_delay_for_response`]); the value surfaced via
170/// [`Error::RateLimited`] is deliberately left uncapped so callers see the
171/// server's actual suggestion.
172fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
173    let value = headers
174        .get(reqwest::header::RETRY_AFTER)?
175        .to_str()
176        .ok()?
177        .trim();
178
179    // Prefer the delay-seconds form: a bare integer, used verbatim.
180    if let Ok(secs) = value.parse::<u64>() {
181        return Some(Duration::from_secs(secs));
182    }
183
184    // Otherwise try the HTTP-date form, computing the delay from now and
185    // clamping a past date to zero (`duration_since` errors when `when` is
186    // before `now`).
187    let when = httpdate::parse_http_date(value).ok()?;
188    Some(
189        when.duration_since(SystemTime::now())
190            .unwrap_or(Duration::ZERO),
191    )
192}
193
194/// The delay to wait before retrying a retryable response: a `429`'s
195/// `Retry-After` when present (capped at [`RETRY_MAX_DELAY`], not jittered —
196/// it's an explicit server-suggested delay), otherwise jittered exponential
197/// backoff (see [`jittered_backoff_delay`]).
198fn retry_delay_for_response(
199    config: &RetryConfig,
200    status: StatusCode,
201    headers: &reqwest::header::HeaderMap,
202    attempt: u32,
203) -> Duration {
204    if status == StatusCode::TOO_MANY_REQUESTS {
205        if let Some(delay) = parse_retry_after(headers) {
206            return delay.min(RETRY_MAX_DELAY);
207        }
208    }
209    jittered_backoff_delay(config, attempt)
210}
211
212/// The `User-Agent` sent with every request, e.g. `datamaxi-rust/0.4.0`.
213fn user_agent() -> String {
214    concat!("datamaxi-rust/", env!("CARGO_PKG_VERSION")).to_string()
215}
216
217/// Truncate a server error body to at most [`MAX_ERROR_BODY_BYTES`] bytes, on
218/// a UTF-8 char boundary.
219fn truncate_body(mut s: String) -> String {
220    if s.len() > MAX_ERROR_BODY_BYTES {
221        let mut end = MAX_ERROR_BODY_BYTES;
222        while !s.is_char_boundary(end) {
223            end -= 1;
224        }
225        s.truncate(end);
226    }
227    s
228}
229
230/// Maps a terminal status — anything other than `200 OK`, `400`, and `500`
231/// (which need per-flavor body handling) — to the corresponding [`Error`].
232/// Returns `None` for those three statuses, leaving them to the caller.
233/// Shared by the async and blocking `handle_response`.
234fn map_error_status(
235    status: StatusCode,
236    headers: &reqwest::header::HeaderMap,
237    endpoint: &str,
238) -> Option<Error> {
239    match status {
240        StatusCode::UNAUTHORIZED => Some(Error::Unauthorized {
241            endpoint: endpoint.to_string(),
242        }),
243        StatusCode::FORBIDDEN => Some(Error::Forbidden {
244            endpoint: endpoint.to_string(),
245        }),
246        StatusCode::NOT_FOUND => Some(Error::NotFound {
247            endpoint: endpoint.to_string(),
248        }),
249        StatusCode::TOO_MANY_REQUESTS => Some(Error::RateLimited {
250            endpoint: endpoint.to_string(),
251            retry_after: parse_retry_after(headers),
252        }),
253        _ => None,
254    }
255}
256
257/// Shared mutable state behind [`ClientBuilder`] and
258/// [`sync::ClientBuilder`]: the four knobs (API key, base URL, timeout,
259/// retry policy) plus the logic to resolve them at `build()` time. Each
260/// flavor's builder is a thin wrapper that forwards its setters here and
261/// supplies its own `build_inner_client` to construct the right `Client`.
262#[derive(Debug, Clone)]
263struct BuilderState {
264    base_url: Option<String>,
265    api_key: Option<String>,
266    timeout: Duration,
267    retry: RetryConfig,
268}
269
270/// The pieces a flavor's `build()` needs, once [`BuilderState::resolve`] has
271/// applied the API key / base URL defaults.
272struct ResolvedBuilder {
273    api_key: String,
274    base_url: String,
275    timeout: Duration,
276    retry: RetryConfig,
277}
278
279impl BuilderState {
280    fn new() -> Self {
281        BuilderState {
282            base_url: None,
283            api_key: None,
284            timeout: DEFAULT_TIMEOUT,
285            retry: RetryConfig::default(),
286        }
287    }
288
289    fn api_key(&mut self, api_key: impl Into<String>) {
290        self.api_key = Some(api_key.into());
291    }
292
293    fn base_url(&mut self, base_url: impl Into<String>) {
294        self.base_url = Some(base_url.into());
295    }
296
297    fn timeout(&mut self, timeout: Duration) {
298        self.timeout = timeout;
299    }
300
301    fn max_retries(&mut self, max_retries: u32) {
302        self.retry.max_retries = max_retries;
303    }
304
305    fn retry_base_delay(&mut self, base_delay: Duration) {
306        self.retry.base_delay = base_delay;
307    }
308
309    /// Resolves the API key from the explicit value or the `DATAMAXI_API_KEY`
310    /// environment variable, returning [`Error::MissingApiKey`] if neither is
311    /// set, and the base URL from the explicit value or [`BASE_URL`].
312    fn resolve(self) -> Result<ResolvedBuilder> {
313        let api_key = self
314            .api_key
315            .or_else(|| std::env::var(API_KEY_ENV).ok())
316            .filter(|key| !key.trim().is_empty())
317            .ok_or(Error::MissingApiKey)?;
318        let base_url = self.base_url.unwrap_or_else(|| BASE_URL.to_string());
319
320        Ok(ResolvedBuilder {
321            api_key,
322            base_url,
323            timeout: self.timeout,
324            retry: self.retry,
325        })
326    }
327}
328
329/// Generates the retry loop shared by [`Client::get`] and
330/// [`sync::Client::get`]. The two flavors are identical except for
331/// whether `send`, the backoff sleep, and `handle_response` are awaited: pass
332/// `await` as the trailing argument for the async flavor, and omit it for the
333/// blocking flavor.
334macro_rules! get_loop {
335    ($self:expr, $endpoint:expr, $parameters:expr, $handle_response:path, $sleep:path $(, $aw:ident)?) => {{
336        let url: String = format!("{}{}", $self.inner.base_url, $endpoint);
337        let mut attempt: u32 = 0;
338
339        loop {
340            #[cfg(feature = "tracing")]
341            tracing::Span::current().record("attempt", attempt as u64);
342
343            let mut request = $self
344                .inner
345                .inner_client
346                .get(url.as_str())
347                .header("X-DTMX-APIKEY", &$self.inner.api_key);
348
349            if let Some(ref p) = $parameters {
350                request = request.query(p);
351            }
352
353            match request.send()$(.$aw)? {
354                Ok(response) => {
355                    let status = response.status();
356                    #[cfg(feature = "tracing")]
357                    tracing::Span::current().record("status", status.as_u16() as u64);
358
359                    if attempt < $self.inner.retry.max_retries && is_retryable_status(status) {
360                        let delay = retry_delay_for_response(
361                            &$self.inner.retry,
362                            status,
363                            response.headers(),
364                            attempt,
365                        );
366                        #[cfg(feature = "tracing")]
367                        tracing::debug!(
368                            target: "datamaxi::retry",
369                            attempt = attempt as u64,
370                            status = status.as_u16() as u64,
371                            delay_ms = delay.as_millis() as u64,
372                            "retrying transient response"
373                        );
374                        attempt += 1;
375                        $sleep(delay)$(.$aw)?;
376                        continue;
377                    }
378                    return $handle_response(response, $endpoint)$(.$aw)?;
379                }
380                Err(error) => {
381                    if attempt < $self.inner.retry.max_retries && is_retryable_error(&error) {
382                        let delay = jittered_backoff_delay(&$self.inner.retry, attempt);
383                        #[cfg(feature = "tracing")]
384                        tracing::debug!(
385                            target: "datamaxi::retry",
386                            attempt = attempt as u64,
387                            delay_ms = delay.as_millis() as u64,
388                            error = %error,
389                            "retrying after transport error"
390                        );
391                        attempt += 1;
392                        $sleep(delay)$(.$aw)?;
393                        continue;
394                    }
395                    #[cfg(feature = "tracing")]
396                    tracing::warn!(target: "datamaxi::retry", error = %error, "request failed");
397                    return Err(Error::from(error));
398                }
399            }
400        }
401    }};
402}
403
404/// Build the underlying async HTTP client with our defaults (timeout,
405/// `User-Agent`, unbounded idle pool). Falls back to a default client if the
406/// builder fails, so client construction is infallible and never panics.
407fn build_inner_client(timeout: Duration) -> reqwest::Client {
408    reqwest::Client::builder()
409        .pool_idle_timeout(None)
410        .timeout(timeout)
411        .user_agent(user_agent())
412        .build()
413        .unwrap_or_else(|_| reqwest::Client::new())
414}
415
416/// Shared, immutable inner state of a [`Client`], held behind an [`Arc`] so
417/// that [`Client::clone`] — done once per endpoint-accessor call, e.g.
418/// [`Client::cex_candle`] — is a single refcount bump rather than re-allocating
419/// the `base_url` / `api_key` strings each time. `reqwest::Client` is already an
420/// `Arc` internally; wrapping the whole set of fields makes the two `String`s
421/// cheap to clone too.
422struct ClientInner {
423    base_url: String,
424    api_key: String,
425    inner_client: reqwest::Client,
426    retry: RetryConfig,
427}
428
429/// The async client for interacting with the Datamaxi+ API.
430///
431/// This is the default surface. For a synchronous client, enable the
432/// `sync` feature and use [`sync::Client`].
433///
434/// Cloning a `Client` is cheap: the shared state lives behind an [`Arc`], so a
435/// clone is a single refcount bump.
436#[derive(Clone)]
437pub struct Client {
438    inner: Arc<ClientInner>,
439}
440
441impl std::fmt::Debug for Client {
442    /// Redacts the API key so it never leaks into logs or error output.
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.debug_struct("Client")
445            .field("base_url", &self.inner.base_url)
446            .field("api_key", &"<redacted>")
447            .finish_non_exhaustive()
448    }
449}
450
451impl Client {
452    /// Creates a new client authenticating with the given API key.
453    ///
454    /// Uses the production base URL and the default timeout
455    /// ([`DEFAULT_TIMEOUT`]). For control over the base URL, timeout, or
456    /// reading the API key from the environment, use [`ClientBuilder`]. Endpoint
457    /// groups are reached via accessors, e.g. [`Client::cex_candle`].
458    pub fn new(api_key: impl Into<String>) -> Self {
459        Client {
460            inner: Arc::new(ClientInner {
461                base_url: BASE_URL.to_string(),
462                api_key: api_key.into(),
463                inner_client: build_inner_client(DEFAULT_TIMEOUT),
464                retry: RetryConfig::default(),
465            }),
466        }
467    }
468
469    /// Sends a GET request to the specified endpoint with optional parameters.
470    ///
471    /// Transient failures (timeouts, connection errors, `429`, and `5xx`) are
472    /// retried per the client's [`RetryConfig`] with exponential backoff; a
473    /// `429` honors its `Retry-After` header. Fatal statuses
474    /// (`400`/`401`/`403`/`404`) are returned without retry.
475    ///
476    /// With the `tracing` feature enabled, each call is wrapped in a span
477    /// carrying `method`, `endpoint`, `attempt`, and the resolved `status`;
478    /// retries additionally emit a debug event with the backoff delay. The
479    /// API key is never recorded.
480    #[cfg_attr(
481        feature = "tracing",
482        tracing::instrument(
483            name = "datamaxi.get",
484            skip(self, parameters),
485            fields(method = "GET", attempt = tracing::field::Empty, status = tracing::field::Empty)
486        )
487    )]
488    pub async fn get<T: DeserializeOwned>(
489        &self,
490        endpoint: &str,
491        parameters: Option<BTreeMap<String, String>>,
492    ) -> Result<T> {
493        get_loop!(
494            self,
495            endpoint,
496            parameters,
497            handle_response,
498            tokio::time::sleep,
499            await
500        )
501    }
502
503    /// Returns an auto-paginator over a paged endpoint (see [`Paginated`]).
504    ///
505    /// `params` seeds the query string for every page (e.g. `limit`, `sort`,
506    /// filters); a `page` key in `params` sets the starting page (defaults to
507    /// `1`) and is overwritten on each subsequent request as the paginator
508    /// advances. Call [`Paginator::next_page`] in a loop to walk forward.
509    pub fn paginate<T>(
510        &self,
511        endpoint: impl Into<String>,
512        params: BTreeMap<String, String>,
513    ) -> Paginator<T>
514    where
515        T: Paginated + DeserializeOwned,
516    {
517        Paginator::new(self.clone(), endpoint, params)
518    }
519}
520
521/// Implemented by paged response envelopes — the `page`/`limit`/`data` shape
522/// shared by several Datamaxi+ endpoints, with or without a `total` (e.g.
523/// `CexAnnouncementsResponse` reports `total`; `FundingRateHistoryResponse`
524/// does not) — so [`Client::paginate`] / [`sync::Client::paginate`] can
525/// drive a generic auto-paginator over them without codegen needing to emit a
526/// bespoke helper per endpoint.
527///
528/// Envelopes without a `total` (see [`Paginated::total`]) auto-paginate the
529/// same way, just terminating only on an empty page rather than also on
530/// `page * limit >= total`.
531pub trait Paginated {
532    /// The item type yielded per page (the envelope's `data` element type).
533    type Item;
534
535    /// The 1-based page number this response represents.
536    fn page(&self) -> i64;
537
538    /// The page size requested/echoed back by the server.
539    fn limit(&self) -> i64;
540
541    /// The total number of items across all pages, if the envelope reports one.
542    fn total(&self) -> Option<i64>;
543
544    /// Consumes the response, yielding this page's items.
545    fn into_items(self) -> Vec<Self::Item>;
546}
547
548/// Implements [`Paginated`] for a `page`/`limit`/`total`/`data` response
549/// envelope, mapping its fields directly (`data` -> items).
550///
551/// A second arm, `impl_paginated!($response, $item, no_total)`, covers the
552/// `page`/`limit`/`data` shape without a `total` field: [`Paginated::total`]
553/// returns `None`, so [`consume_page`] terminates only on an empty page.
554macro_rules! impl_paginated {
555    ($response:ty, $item:ty) => {
556        impl Paginated for $response {
557            type Item = $item;
558
559            fn page(&self) -> i64 {
560                self.page
561            }
562
563            fn limit(&self) -> i64 {
564                self.limit
565            }
566
567            fn total(&self) -> Option<i64> {
568                Some(self.total)
569            }
570
571            fn into_items(self) -> Vec<Self::Item> {
572                self.data
573            }
574        }
575    };
576    ($response:ty, $item:ty, no_total) => {
577        impl Paginated for $response {
578            type Item = $item;
579
580            fn page(&self) -> i64 {
581                self.page
582            }
583
584            fn limit(&self) -> i64 {
585                self.limit
586            }
587
588            fn total(&self) -> Option<i64> {
589                None
590            }
591
592            fn into_items(self) -> Vec<Self::Item> {
593                self.data
594            }
595        }
596    };
597}
598
599impl_paginated!(
600    crate::generated::CexAnnouncementsResponse,
601    crate::generated::CexAnnouncementsView
602);
603impl_paginated!(
604    crate::generated::CexTokenUpdatesResponse,
605    crate::generated::CexTokenUpdatesView
606);
607impl_paginated!(
608    crate::generated::OpenInterestOverviewResponse,
609    crate::generated::OpenInterestOverviewView
610);
611impl_paginated!(
612    crate::generated::PremiumResponse,
613    crate::generated::PremiumView
614);
615impl_paginated!(
616    crate::generated::TelegramChannelsResponse,
617    crate::generated::TelegramChannelsView
618);
619impl_paginated!(
620    crate::generated::TelegramMessagesResponse,
621    crate::generated::TelegramMessagesView
622);
623impl_paginated!(
624    crate::generated::FundingRateHistoryResponse,
625    crate::generated::FundingRateHistoryView,
626    no_total
627);
628
629/// Async auto-paginator returned by [`Client::paginate`].
630///
631/// `next_page` is a plain cursor, with no dependency on `futures`:
632///
633/// ```no_run
634/// use datamaxi::api::ClientBuilder;
635/// use datamaxi::CexAnnouncementsResponse;
636/// use std::collections::BTreeMap;
637///
638/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
639/// let client = ClientBuilder::new().api_key("my_api_key").build()?;
640/// let mut pages = client
641///     .paginate::<CexAnnouncementsResponse>("/api/v1/cex/announcements", BTreeMap::new());
642///
643/// while let Some(items) = pages.next_page().await? {
644///     for item in items {
645///         println!("{}", item.title);
646///     }
647/// }
648/// # Ok(())
649/// # }
650/// ```
651///
652/// With the opt-in `stream` feature, [`Paginator`] also implements
653/// [`Stream`](https://docs.rs/futures-core/latest/futures_core/stream/trait.Stream.html)
654/// (the trait re-exported as `futures::Stream` by the
655/// [`futures`](https://docs.rs/futures) crate), yielding one
656/// `Result<Vec<T::Item>>` per page, so it composes with `futures`/`StreamExt`
657/// combinators and `.next().await`:
658///
659/// ```no_run
660/// # #[cfg(feature = "stream")]
661/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
662/// use datamaxi::api::ClientBuilder;
663/// use datamaxi::CexAnnouncementsResponse;
664/// use futures::StreamExt;
665/// use std::collections::BTreeMap;
666///
667/// let client = ClientBuilder::new().api_key("my_api_key").build()?;
668/// let mut pages = client
669///     .paginate::<CexAnnouncementsResponse>("/api/v1/cex/announcements", BTreeMap::new());
670///
671/// while let Some(page) = pages.next().await {
672///     for item in page? {
673///         println!("{}", item.title);
674///     }
675/// }
676/// # Ok(())
677/// # }
678/// ```
679pub struct Paginator<T: Paginated> {
680    client: Client,
681    endpoint: String,
682    params: BTreeMap<String, String>,
683    next_page: i64,
684    done: bool,
685    _marker: PhantomData<T>,
686    /// The in-flight `next_page()` future backing [`futures_core::Stream::poll_next`],
687    /// present only under the `stream` feature. Built from owned clones of
688    /// `client`/`endpoint`/`params` (rather than borrowing `self`) so it can
689    /// be stored across `poll_next` calls without a self-referential struct.
690    #[cfg(feature = "stream")]
691    pending: Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<T>> + Send>>>,
692}
693
694impl<T> Paginator<T>
695where
696    T: Paginated + DeserializeOwned,
697{
698    fn new(client: Client, endpoint: impl Into<String>, params: BTreeMap<String, String>) -> Self {
699        let next_page = starting_page(&params);
700        Paginator {
701            client,
702            endpoint: endpoint.into(),
703            params,
704            next_page,
705            done: false,
706            _marker: PhantomData,
707            #[cfg(feature = "stream")]
708            pending: None,
709        }
710    }
711
712    /// Fetches and returns the next page's items, or `Ok(None)` once the
713    /// server has no more data: an empty page, or (when the envelope reports
714    /// a `total`) a page reaching `page * limit >= total`. Once exhausted,
715    /// further calls keep returning `Ok(None)` rather than re-fetching.
716    pub async fn next_page(&mut self) -> Result<Option<Vec<T::Item>>> {
717        if self.done {
718            return Ok(None);
719        }
720
721        let mut params = self.params.clone();
722        params.insert("page".to_string(), self.next_page.to_string());
723
724        let response: T = self.client.get(&self.endpoint, Some(params)).await?;
725        Ok(self.consume_response(response))
726    }
727
728    /// Shared bookkeeping for a fetched page: advances `next_page`, marks
729    /// [`Paginator::done`] on an empty page or on reaching `total`, and
730    /// extracts the items. Kept free of `async`/blocking specifics so both
731    /// flavors share the exact same terminal-condition logic.
732    fn consume_response(&mut self, response: T) -> Option<Vec<T::Item>> {
733        consume_page(&mut self.next_page, &mut self.done, response)
734    }
735}
736
737/// [`futures_core::Stream`] over [`Paginator`]'s pages, gated by the `stream`
738/// feature so the `futures-core` dependency stays opt-in. Yields one
739/// `Result<Vec<T::Item>>` per page and, like [`Iterator`] for
740/// [`sync::Paginator`], stops (yields `None`) once the server reports no
741/// more data, and after the first `Err`.
742///
743/// Each poll drives an owned future built from clones of `client`/`endpoint`/
744/// `params` (see [`Paginator::pending`]) rather than borrowing `self`, so the
745/// future can be stored across `poll_next` calls without a self-referential
746/// struct; on `Poll::Ready`, the same [`consume_page`] bookkeeping used by
747/// `next_page` updates `next_page`/`done`.
748#[cfg(feature = "stream")]
749#[cfg_attr(docsrs, doc(cfg(feature = "stream")))]
750impl<T> futures_core::Stream for Paginator<T>
751where
752    T: Paginated + DeserializeOwned + Send + Unpin + 'static,
753{
754    type Item = Result<Vec<T::Item>>;
755
756    fn poll_next(
757        self: std::pin::Pin<&mut Self>,
758        cx: &mut std::task::Context<'_>,
759    ) -> std::task::Poll<Option<Self::Item>> {
760        use std::task::Poll;
761
762        let this = self.get_mut();
763
764        if this.done {
765            return Poll::Ready(None);
766        }
767
768        if this.pending.is_none() {
769            let client = this.client.clone();
770            let endpoint = this.endpoint.clone();
771            let mut params = this.params.clone();
772            params.insert("page".to_string(), this.next_page.to_string());
773            this.pending = Some(Box::pin(async move {
774                client.get::<T>(&endpoint, Some(params)).await
775            }));
776        }
777
778        let pending = this
779            .pending
780            .as_mut()
781            .expect("pending future was just set above");
782        match pending.as_mut().poll(cx) {
783            Poll::Pending => Poll::Pending,
784            Poll::Ready(result) => {
785                this.pending = None;
786                match result {
787                    Ok(response) => Poll::Ready(
788                        consume_page(&mut this.next_page, &mut this.done, response).map(Ok),
789                    ),
790                    Err(error) => {
791                        // Stop the stream after an error rather than retrying
792                        // the same page forever, mirroring the blocking
793                        // `Iterator` impl.
794                        this.done = true;
795                        Poll::Ready(Some(Err(error)))
796                    }
797                }
798            }
799        }
800    }
801}
802
803/// The starting page for a paginator: the caller-supplied `page` param when
804/// present and positive, otherwise `1`.
805fn starting_page(params: &BTreeMap<String, String>) -> i64 {
806    params
807        .get("page")
808        .and_then(|p| p.parse::<i64>().ok())
809        .filter(|&p| p > 0)
810        .unwrap_or(1)
811}
812
813/// Shared terminal-condition logic for both the async and blocking
814/// paginators: given a fetched `response`, updates `next_page`/`done` and
815/// returns `Some(items)`, or `None` once there is nothing left to yield.
816fn consume_page<T: Paginated>(
817    next_page: &mut i64,
818    done: &mut bool,
819    response: T,
820) -> Option<Vec<T::Item>> {
821    let page = response.page();
822    let limit = response.limit();
823    let total = response.total();
824    let items = response.into_items();
825
826    if items.is_empty() {
827        *done = true;
828        return None;
829    }
830
831    *next_page = page + 1;
832    if let Some(total) = total {
833        if limit > 0 && page.saturating_mul(limit) >= total {
834            *done = true;
835        }
836    }
837
838    Some(items)
839}
840
841/// Reads at most [`MAX_ERROR_BODY_BYTES`] of an async response body, streaming
842/// chunk by chunk rather than buffering the whole body. Mirrors the blocking
843/// path's `response.take(MAX_ERROR_BODY_BYTES).read_to_string(&mut body)`.
844/// Invalid UTF-8 in the truncated bytes is replaced lossily.
845async fn read_body_capped(mut response: reqwest::Response) -> String {
846    let mut buf: Vec<u8> = Vec::new();
847    while buf.len() < MAX_ERROR_BODY_BYTES {
848        match response.chunk().await {
849            Ok(Some(chunk)) => {
850                // Take only up to the remaining budget so a single oversized
851                // chunk can't push `buf` past the cap — a byte-exact bound
852                // matching the blocking path's `response.take(MAX_ERROR_BODY_BYTES)`.
853                let take = (MAX_ERROR_BODY_BYTES - buf.len()).min(chunk.len());
854                buf.extend_from_slice(&chunk[..take]);
855            }
856            Ok(None) => break,
857            Err(_) => break,
858        }
859    }
860    truncate_body(String::from_utf8_lossy(&buf).into_owned())
861}
862
863/// Processes an async response from the API and returns the result. `endpoint`
864/// is the request path, attached to the returned [`Error`] for diagnosability.
865async fn handle_response<T: DeserializeOwned>(
866    response: reqwest::Response,
867    endpoint: &str,
868) -> Result<T> {
869    match response.status() {
870        StatusCode::OK => Ok(response.json::<T>().await?),
871        StatusCode::INTERNAL_SERVER_ERROR => Err(Error::InternalServerError {
872            endpoint: endpoint.to_string(),
873            body: read_body_capped(response).await,
874        }),
875        StatusCode::BAD_REQUEST => Err(Error::BadRequest {
876            endpoint: endpoint.to_string(),
877            body: read_body_capped(response).await,
878        }),
879        status => match map_error_status(status, response.headers(), endpoint) {
880            Some(err) => Err(err),
881            None => {
882                let code = status.as_u16();
883                Err(Error::UnexpectedStatusCode {
884                    endpoint: endpoint.to_string(),
885                    status: code,
886                    body: read_body_capped(response).await,
887                })
888            }
889        },
890    }
891}
892
893/// Builder for a [`Client`], giving control over the API key source, base URL,
894/// and request timeout.
895///
896/// The API key may be provided explicitly via [`api_key`](ClientBuilder::api_key)
897/// or, if omitted, is read from the `DATAMAXI_API_KEY` environment variable at
898/// [`build`](ClientBuilder::build) time.
899///
900/// # Example
901/// ```no_run
902/// use datamaxi::api::ClientBuilder;
903/// use std::time::Duration;
904///
905/// // Explicit key + custom timeout.
906/// let client = ClientBuilder::new()
907///     .api_key("my_api_key")
908///     .timeout(Duration::from_secs(30))
909///     .build()
910///     .expect("api key provided");
911///
912/// // Key taken from the DATAMAXI_API_KEY environment variable.
913/// let client = ClientBuilder::new().build();
914/// ```
915#[derive(Debug, Clone)]
916pub struct ClientBuilder {
917    state: BuilderState,
918    http_client: Option<reqwest::Client>,
919}
920
921impl ClientBuilder {
922    /// Creates a new builder with default settings (default timeout, no retries,
923    /// key read from the environment on `build`).
924    pub fn new() -> Self {
925        ClientBuilder {
926            state: BuilderState::new(),
927            http_client: None,
928        }
929    }
930
931    /// Sets the API key explicitly, overriding the `DATAMAXI_API_KEY` environment variable.
932    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
933        self.state.api_key(api_key);
934        self
935    }
936
937    /// Overrides the base URL (defaults to the production API).
938    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
939        self.state.base_url(base_url);
940        self
941    }
942
943    /// Sets the per-request timeout (defaults to 10 seconds).
944    pub fn timeout(mut self, timeout: Duration) -> Self {
945        self.state.timeout(timeout);
946        self
947    }
948
949    /// Sets the maximum number of retries on transient failures (timeouts,
950    /// connection errors, `429`, and `5xx`). Defaults to `0` (no retries);
951    /// each retry backs off exponentially from the base delay.
952    pub fn max_retries(mut self, max_retries: u32) -> Self {
953        self.state.max_retries(max_retries);
954        self
955    }
956
957    /// Sets the base delay for exponential retry backoff (defaults to 500ms).
958    /// The nth retry waits `base_delay * 2^n`, capped at 30 seconds.
959    pub fn retry_base_delay(mut self, base_delay: Duration) -> Self {
960        self.state.retry_base_delay(base_delay);
961        self
962    }
963
964    /// Overrides the internally-built `datamaxi::reqwest::Client` with a
965    /// caller-supplied one — the escape hatch for custom middleware,
966    /// timeouts, proxies, or instrumentation (e.g. a `reqwest-middleware`
967    /// client wrapped down to its inner `reqwest::Client`, or one built with
968    /// `reqwest_tracing`). Use the crate's re-exported [`crate::reqwest`] to
969    /// build it, so the type always matches without a version mismatch.
970    ///
971    /// When set, [`ClientBuilder::timeout`] is ignored for HTTP-level
972    /// settings (the caller's client is used as-is); [`build`](Self::build)
973    /// no longer applies the built-in `User-Agent` / pool defaults.
974    pub fn http_client(mut self, client: reqwest::Client) -> Self {
975        self.http_client = Some(client);
976        self
977    }
978
979    /// Builds the [`Client`].
980    ///
981    /// Resolves the API key from the explicit value or the `DATAMAXI_API_KEY`
982    /// environment variable, returning [`Error::MissingApiKey`] if neither is set.
983    pub fn build(self) -> Result<Client> {
984        let resolved = self.state.resolve()?;
985        let inner_client = self
986            .http_client
987            .unwrap_or_else(|| build_inner_client(resolved.timeout));
988
989        Ok(Client {
990            inner: Arc::new(ClientInner {
991                base_url: resolved.base_url,
992                api_key: resolved.api_key,
993                inner_client,
994                retry: resolved.retry,
995            }),
996        })
997    }
998}
999
1000impl Default for ClientBuilder {
1001    fn default() -> Self {
1002        Self::new()
1003    }
1004}
1005
1006/// A specialized [`Result`](std::result::Result) type for Datamaxi+ API calls.
1007pub type Result<T> = std::result::Result<T, Error>;
1008
1009/// Errors returned by the Datamaxi+ API client.
1010#[derive(Debug, Error)]
1011#[non_exhaustive]
1012pub enum Error {
1013    /// No API key was provided explicitly and `DATAMAXI_API_KEY` is unset or empty.
1014    #[error("missing API key: pass it to ClientBuilder::api_key or set DATAMAXI_API_KEY")]
1015    MissingApiKey,
1016
1017    /// The API returned a `400 Bad Request`; the payload carries the server message.
1018    ///
1019    /// `endpoint` is the request path that failed (e.g.
1020    /// `/api/v1/liquidation/heatmap`), so failures are diagnosable without
1021    /// enabling `tracing`.
1022    #[error("Bad request ({endpoint}): {body}")]
1023    BadRequest {
1024        /// The request path that produced this error.
1025        endpoint: String,
1026        /// The server message (capped, possibly empty).
1027        body: String,
1028    },
1029
1030    /// The API returned a `401 Unauthorized` (missing or invalid API key).
1031    #[error("Unauthorized ({endpoint})")]
1032    Unauthorized {
1033        /// The request path that produced this error.
1034        endpoint: String,
1035    },
1036
1037    /// The API returned a `403 Forbidden` (the key is valid but lacks access to the resource).
1038    #[error("Forbidden ({endpoint})")]
1039    Forbidden {
1040        /// The request path that produced this error.
1041        endpoint: String,
1042    },
1043
1044    /// The API returned a `404 Not Found` (the resource or endpoint does not exist).
1045    ///
1046    /// `endpoint` names which of the endpoints 404'd, so the error is
1047    /// actionable on its own.
1048    #[error("Not found ({endpoint})")]
1049    NotFound {
1050        /// The request path that produced this error.
1051        endpoint: String,
1052    },
1053
1054    /// The API returned a `429 Too Many Requests` (rate limited).
1055    ///
1056    /// `retry_after` carries the `Retry-After` header when present, parsed from
1057    /// either the delay-seconds or the HTTP-date form (a past date clamps to
1058    /// zero). This is the server's actual suggestion and is **not** clamped to
1059    /// [`RETRY_MAX_DELAY`] (that cap only bounds the client's internal retry
1060    /// sleeps).
1061    #[error("Rate limited ({endpoint})")]
1062    RateLimited {
1063        /// The request path that produced this error.
1064        endpoint: String,
1065        /// Suggested wait before retrying, from the `Retry-After` header
1066        /// (raw, uncapped).
1067        retry_after: Option<Duration>,
1068    },
1069
1070    /// The API returned a `500 Internal Server Error`; the payload carries the server message.
1071    #[error("Internal server error ({endpoint}): {body}")]
1072    InternalServerError {
1073        /// The request path that produced this error.
1074        endpoint: String,
1075        /// The server message (capped, possibly empty).
1076        body: String,
1077    },
1078
1079    /// The API returned a status code the client does not specifically handle.
1080    ///
1081    /// Carries the request `endpoint`, the raw status code, and the (capped)
1082    /// response body. An unexpected status is exactly the case where the body
1083    /// is most useful for diagnosis, so — like [`Error::BadRequest`] /
1084    /// [`Error::InternalServerError`] — it is preserved rather than discarded.
1085    /// The body is truncated to a capped length on a UTF-8 char boundary.
1086    #[error("Received unexpected status code {status} ({endpoint}): {body}")]
1087    UnexpectedStatusCode {
1088        /// The request path that produced this error.
1089        endpoint: String,
1090        /// The raw HTTP status code.
1091        status: u16,
1092        /// The response body (capped, possibly empty).
1093        body: String,
1094    },
1095
1096    /// The underlying HTTP request failed, or the response body could not be
1097    /// decoded. The failing URL is available via
1098    /// [`reqwest::Error::url`](reqwest::Error::url) on the wrapped error.
1099    #[error(transparent)]
1100    Http(#[from] reqwest::Error),
1101
1102    /// Reading the response body failed.
1103    #[error(transparent)]
1104    Io(#[from] std::io::Error),
1105}
1106
1107/// Synchronous client surface, enabled by the `sync` feature.
1108///
1109/// Mirrors the async [`Client`] with the same status-to-[`Error`] mapping, for
1110/// scripts, notebooks, and other callers that don't run an async runtime. The
1111/// generated endpoint wrappers under [`crate::generated::sync_internal`] use this.
1112#[cfg(feature = "sync")]
1113#[cfg_attr(docsrs, doc(cfg(feature = "sync")))]
1114pub mod sync {
1115    use super::{
1116        consume_page, is_retryable_error, is_retryable_status, jittered_backoff_delay,
1117        map_error_status, retry_delay_for_response, starting_page, truncate_body, user_agent,
1118        BuilderState, Error, Paginated, Result, RetryConfig, BASE_URL, DEFAULT_TIMEOUT,
1119        MAX_ERROR_BODY_BYTES,
1120    };
1121    use reqwest::blocking::Response;
1122    use reqwest::StatusCode;
1123    use serde::de::DeserializeOwned;
1124    use std::collections::BTreeMap;
1125    use std::io::Read;
1126    use std::marker::PhantomData;
1127    use std::sync::Arc;
1128    use std::time::Duration;
1129
1130    /// Build the underlying blocking HTTP client with our defaults. Falls back
1131    /// to a default client if the builder fails, so construction never panics.
1132    fn build_inner_client(timeout: Duration) -> reqwest::blocking::Client {
1133        reqwest::blocking::Client::builder()
1134            .pool_idle_timeout(None)
1135            .timeout(timeout)
1136            .user_agent(user_agent())
1137            .build()
1138            .unwrap_or_else(|_| reqwest::blocking::Client::new())
1139    }
1140
1141    /// Shared, immutable inner state of a blocking [`Client`], held behind an
1142    /// [`Arc`] so cloning is a single refcount bump. Mirrors the async
1143    /// [`super::ClientInner`].
1144    struct ClientInner {
1145        base_url: String,
1146        api_key: String,
1147        inner_client: reqwest::blocking::Client,
1148        retry: RetryConfig,
1149    }
1150
1151    /// The blocking client for interacting with the Datamaxi+ API.
1152    ///
1153    /// Cloning is cheap: the shared state lives behind an [`Arc`], so a clone is
1154    /// a single refcount bump.
1155    #[derive(Clone)]
1156    pub struct Client {
1157        inner: Arc<ClientInner>,
1158    }
1159
1160    impl std::fmt::Debug for Client {
1161        /// Redacts the API key so it never leaks into logs or error output.
1162        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1163            f.debug_struct("Client")
1164                .field("base_url", &self.inner.base_url)
1165                .field("api_key", &"<redacted>")
1166                .finish_non_exhaustive()
1167        }
1168    }
1169
1170    impl Client {
1171        /// Creates a new blocking `Client` authenticating with the given API
1172        /// key, using the production base URL and the default timeout. For more
1173        /// control, use [`ClientBuilder`]. Endpoint groups are reached via
1174        /// accessors, e.g. [`Client::cex_candle`].
1175        pub fn new(api_key: impl Into<String>) -> Self {
1176            Client {
1177                inner: Arc::new(ClientInner {
1178                    base_url: BASE_URL.to_string(),
1179                    api_key: api_key.into(),
1180                    inner_client: build_inner_client(DEFAULT_TIMEOUT),
1181                    retry: RetryConfig::default(),
1182                }),
1183            }
1184        }
1185
1186        /// Sends a GET request to the specified endpoint with optional parameters.
1187        ///
1188        /// Mirrors the async [`super::Client::get`] retry behavior: transient
1189        /// failures (timeouts, connection errors, `429`, and `5xx`) are retried
1190        /// per the client's retry config with exponential backoff (a `429`
1191        /// honors `Retry-After`); fatal statuses are returned without retry.
1192        /// Backoff waits use a blocking [`std::thread::sleep`].
1193        ///
1194        /// With the `tracing` feature enabled, each call is wrapped in a span
1195        /// carrying `method`, `endpoint`, `attempt`, and the resolved
1196        /// `status`; retries additionally emit a debug event with the
1197        /// backoff delay. The API key is never recorded.
1198        #[cfg_attr(
1199            feature = "tracing",
1200            tracing::instrument(
1201                name = "datamaxi.get",
1202                skip(self, parameters),
1203                fields(method = "GET", attempt = tracing::field::Empty, status = tracing::field::Empty)
1204            )
1205        )]
1206        pub fn get<T: DeserializeOwned>(
1207            &self,
1208            endpoint: &str,
1209            parameters: Option<BTreeMap<String, String>>,
1210        ) -> Result<T> {
1211            get_loop!(
1212                self,
1213                endpoint,
1214                parameters,
1215                handle_response,
1216                std::thread::sleep
1217            )
1218        }
1219
1220        /// Returns an auto-paginator over a paged endpoint (see
1221        /// [`super::Paginated`]). Mirrors the async [`super::Client::paginate`];
1222        /// see its docs for how `params` and the starting page work.
1223        pub fn paginate<T>(
1224            &self,
1225            endpoint: impl Into<String>,
1226            params: BTreeMap<String, String>,
1227        ) -> Paginator<T>
1228        where
1229            T: Paginated + DeserializeOwned,
1230        {
1231            Paginator::new(self.clone(), endpoint, params)
1232        }
1233    }
1234
1235    /// Blocking auto-paginator returned by [`Client::paginate`], implementing
1236    /// [`Iterator`] over pages of items (one `Result<Vec<T::Item>>` per page).
1237    /// Iteration stops (yields `None`) once the server reports no more data,
1238    /// and after the first `Err`, so a caller can `?`-propagate mid-loop
1239    /// without risking a retry of the same failing page.
1240    ///
1241    /// ```no_run
1242    /// use datamaxi::api::sync::ClientBuilder;
1243    /// use datamaxi::CexAnnouncementsResponse;
1244    /// use std::collections::BTreeMap;
1245    ///
1246    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
1247    /// let client = ClientBuilder::new().api_key("my_api_key").build()?;
1248    /// for page in
1249    ///     client.paginate::<CexAnnouncementsResponse>("/api/v1/cex/announcements", BTreeMap::new())
1250    /// {
1251    ///     for item in page? {
1252    ///         println!("{}", item.title);
1253    ///     }
1254    /// }
1255    /// # Ok(())
1256    /// # }
1257    /// ```
1258    pub struct Paginator<T: Paginated> {
1259        client: Client,
1260        endpoint: String,
1261        params: BTreeMap<String, String>,
1262        next_page: i64,
1263        done: bool,
1264        _marker: PhantomData<T>,
1265    }
1266
1267    impl<T> Paginator<T>
1268    where
1269        T: Paginated + DeserializeOwned,
1270    {
1271        fn new(
1272            client: Client,
1273            endpoint: impl Into<String>,
1274            params: BTreeMap<String, String>,
1275        ) -> Self {
1276            let next_page = starting_page(&params);
1277            Paginator {
1278                client,
1279                endpoint: endpoint.into(),
1280                params,
1281                next_page,
1282                done: false,
1283                _marker: PhantomData,
1284            }
1285        }
1286    }
1287
1288    impl<T> Iterator for Paginator<T>
1289    where
1290        T: Paginated + DeserializeOwned,
1291    {
1292        type Item = Result<Vec<T::Item>>;
1293
1294        fn next(&mut self) -> Option<Self::Item> {
1295            if self.done {
1296                return None;
1297            }
1298
1299            let mut params = self.params.clone();
1300            params.insert("page".to_string(), self.next_page.to_string());
1301
1302            match self.client.get::<T>(&self.endpoint, Some(params)) {
1303                Ok(response) => consume_page(&mut self.next_page, &mut self.done, response).map(Ok),
1304                Err(error) => {
1305                    // Stop iterating after an error rather than retrying the
1306                    // same page forever.
1307                    self.done = true;
1308                    Some(Err(error))
1309                }
1310            }
1311        }
1312    }
1313
1314    /// Reads at most [`MAX_ERROR_BODY_BYTES`] of a blocking response body,
1315    /// truncated on a UTF-8 char boundary. The blocking counterpart to the
1316    /// async [`super::read_body_capped`]; shared by the `400` and `500` arms of
1317    /// [`handle_response`] so the cap and truncation stay in one place.
1318    fn read_body_capped(response: Response) -> std::io::Result<String> {
1319        let mut body = String::new();
1320        response
1321            .take(MAX_ERROR_BODY_BYTES as u64)
1322            .read_to_string(&mut body)?;
1323        Ok(truncate_body(body))
1324    }
1325
1326    /// Processes a blocking response from the API and returns the result.
1327    /// `endpoint` is the request path, attached to the returned [`Error`] for
1328    /// diagnosability.
1329    fn handle_response<T: DeserializeOwned>(response: Response, endpoint: &str) -> Result<T> {
1330        match response.status() {
1331            StatusCode::OK => Ok(response.json::<T>()?),
1332            StatusCode::INTERNAL_SERVER_ERROR => Err(Error::InternalServerError {
1333                endpoint: endpoint.to_string(),
1334                body: read_body_capped(response)?,
1335            }),
1336            StatusCode::BAD_REQUEST => Err(Error::BadRequest {
1337                endpoint: endpoint.to_string(),
1338                body: read_body_capped(response)?,
1339            }),
1340            status => match map_error_status(status, response.headers(), endpoint) {
1341                Some(err) => Err(err),
1342                None => {
1343                    let code = status.as_u16();
1344                    Err(Error::UnexpectedStatusCode {
1345                        endpoint: endpoint.to_string(),
1346                        status: code,
1347                        body: read_body_capped(response)?,
1348                    })
1349                }
1350            },
1351        }
1352    }
1353
1354    /// Builder for a blocking [`Client`], mirroring the async [`super::ClientBuilder`].
1355    #[derive(Debug, Clone)]
1356    pub struct ClientBuilder {
1357        state: BuilderState,
1358        http_client: Option<reqwest::blocking::Client>,
1359    }
1360
1361    impl ClientBuilder {
1362        /// Creates a new builder with default settings.
1363        pub fn new() -> Self {
1364            ClientBuilder {
1365                state: BuilderState::new(),
1366                http_client: None,
1367            }
1368        }
1369
1370        /// Sets the API key explicitly, overriding the `DATAMAXI_API_KEY` environment variable.
1371        pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
1372            self.state.api_key(api_key);
1373            self
1374        }
1375
1376        /// Overrides the base URL (defaults to the production API).
1377        pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
1378            self.state.base_url(base_url);
1379            self
1380        }
1381
1382        /// Sets the per-request timeout (defaults to 10 seconds).
1383        pub fn timeout(mut self, timeout: Duration) -> Self {
1384            self.state.timeout(timeout);
1385            self
1386        }
1387
1388        /// Sets the maximum number of retries on transient failures (timeouts,
1389        /// connection errors, `429`, and `5xx`). Defaults to `0` (no retries).
1390        pub fn max_retries(mut self, max_retries: u32) -> Self {
1391            self.state.max_retries(max_retries);
1392            self
1393        }
1394
1395        /// Sets the base delay for exponential retry backoff (defaults to
1396        /// 500ms). The nth retry waits `base_delay * 2^n`, capped at 30 seconds.
1397        pub fn retry_base_delay(mut self, base_delay: Duration) -> Self {
1398            self.state.retry_base_delay(base_delay);
1399            self
1400        }
1401
1402        /// Overrides the internally-built `datamaxi::reqwest::blocking::Client`
1403        /// with a caller-supplied one. Mirrors
1404        /// [`super::ClientBuilder::http_client`] for the blocking flavor; use
1405        /// the crate's re-exported [`crate::reqwest`] to build it, so the
1406        /// type always matches without a version mismatch.
1407        pub fn http_client(mut self, client: reqwest::blocking::Client) -> Self {
1408            self.http_client = Some(client);
1409            self
1410        }
1411
1412        /// Builds the blocking [`Client`], resolving the API key from the
1413        /// explicit value or the `DATAMAXI_API_KEY` environment variable.
1414        pub fn build(self) -> Result<Client> {
1415            let resolved = self.state.resolve()?;
1416            let inner_client = self
1417                .http_client
1418                .unwrap_or_else(|| build_inner_client(resolved.timeout));
1419
1420            Ok(Client {
1421                inner: Arc::new(ClientInner {
1422                    base_url: resolved.base_url,
1423                    api_key: resolved.api_key,
1424                    inner_client,
1425                    retry: resolved.retry,
1426                }),
1427            })
1428        }
1429    }
1430
1431    impl Default for ClientBuilder {
1432        fn default() -> Self {
1433            Self::new()
1434        }
1435    }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441    use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
1442
1443    #[test]
1444    fn retryable_statuses_are_429_and_5xx() {
1445        assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
1446        assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR));
1447        assert!(is_retryable_status(StatusCode::BAD_GATEWAY));
1448        assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE));
1449    }
1450
1451    #[test]
1452    fn fatal_statuses_are_not_retryable() {
1453        for status in [
1454            StatusCode::BAD_REQUEST,
1455            StatusCode::UNAUTHORIZED,
1456            StatusCode::FORBIDDEN,
1457            StatusCode::NOT_FOUND,
1458        ] {
1459            assert!(!is_retryable_status(status), "{status} must not be retried");
1460        }
1461    }
1462
1463    #[test]
1464    fn backoff_is_exponential_and_capped() {
1465        let config = RetryConfig {
1466            max_retries: 5,
1467            base_delay: Duration::from_millis(100),
1468        };
1469        assert_eq!(backoff_delay(&config, 0), Duration::from_millis(100));
1470        assert_eq!(backoff_delay(&config, 1), Duration::from_millis(200));
1471        assert_eq!(backoff_delay(&config, 2), Duration::from_millis(400));
1472        // A large attempt saturates to the hard cap rather than overflowing.
1473        assert_eq!(backoff_delay(&config, 1000), RETRY_MAX_DELAY);
1474    }
1475
1476    #[test]
1477    fn jitter_in_range_is_exact_and_deterministic_with_injected_source() {
1478        let upper = Duration::from_millis(400);
1479
1480        // A stand-in "random" source that always returns the range's upper
1481        // bound picks exactly `upper`.
1482        assert_eq!(jitter_in_range(upper, |range| *range.end()), upper);
1483        // ...and one that always returns the range's lower bound picks zero.
1484        assert_eq!(
1485            jitter_in_range(upper, |range| *range.start()),
1486            Duration::ZERO
1487        );
1488
1489        // A zero upper bound short-circuits to zero without even consulting
1490        // the random source.
1491        assert_eq!(
1492            jitter_in_range(Duration::ZERO, |_| panic!("must not be called")),
1493            Duration::ZERO
1494        );
1495    }
1496
1497    #[test]
1498    fn apply_jitter_stays_within_zero_and_upper() {
1499        // Property check over the real `fastrand` source: the result must
1500        // always land in `[0, upper]`, regardless of the actual random draw,
1501        // so this stays deterministic (always passes) without seeding.
1502        for millis in [0, 1, 100, 30_000, 45_000] {
1503            let upper = Duration::from_millis(millis).min(RETRY_MAX_DELAY);
1504            for _ in 0..200 {
1505                let jittered = apply_jitter(upper);
1506                assert!(
1507                    jittered <= upper,
1508                    "jittered delay {jittered:?} exceeded upper bound {upper:?}"
1509                );
1510            }
1511        }
1512    }
1513
1514    #[test]
1515    fn jittered_backoff_delay_stays_within_zero_and_backoff_delay() {
1516        let config = RetryConfig {
1517            max_retries: 5,
1518            base_delay: Duration::from_millis(100),
1519        };
1520        for attempt in [0, 1, 2, 3, 1000] {
1521            let upper = backoff_delay(&config, attempt);
1522            for _ in 0..200 {
1523                let jittered = jittered_backoff_delay(&config, attempt);
1524                assert!(jittered <= upper);
1525                assert!(jittered <= RETRY_MAX_DELAY);
1526            }
1527        }
1528    }
1529
1530    #[test]
1531    fn parse_retry_after_parses_integer_seconds_uncapped() {
1532        let mut headers = HeaderMap::new();
1533        headers.insert(RETRY_AFTER, HeaderValue::from_static("2"));
1534        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(2)));
1535
1536        // The parser itself never caps; that's the call site's job.
1537        headers.insert(RETRY_AFTER, HeaderValue::from_static("9999"));
1538        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(9999)));
1539    }
1540
1541    #[test]
1542    fn parse_retry_after_missing_or_garbage_is_none() {
1543        let empty = HeaderMap::new();
1544        assert_eq!(parse_retry_after(&empty), None);
1545
1546        let mut headers = HeaderMap::new();
1547        headers.insert(RETRY_AFTER, HeaderValue::from_static("not-a-date"));
1548        assert_eq!(parse_retry_after(&headers), None);
1549    }
1550
1551    #[test]
1552    fn parse_retry_after_past_http_date_clamps_to_zero() {
1553        // A date well in the past: the window has already elapsed, so the delay
1554        // clamps to zero rather than going negative or wrapping.
1555        let mut headers = HeaderMap::new();
1556        headers.insert(
1557            RETRY_AFTER,
1558            HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
1559        );
1560        assert_eq!(parse_retry_after(&headers), Some(Duration::ZERO));
1561    }
1562
1563    #[test]
1564    fn parse_retry_after_future_http_date_is_delay_from_now() {
1565        // Format a date ~1 hour in the future via httpdate, then parse it back:
1566        // the computed delay should be close to an hour (allowing a small
1567        // window for the clock ticking between formatting and parsing).
1568        let future = SystemTime::now() + Duration::from_secs(3600);
1569        let header = httpdate::fmt_http_date(future);
1570        let mut headers = HeaderMap::new();
1571        headers.insert(RETRY_AFTER, HeaderValue::from_str(&header).unwrap());
1572
1573        let delay = parse_retry_after(&headers).expect("future HTTP-date should parse");
1574        assert!(
1575            delay <= Duration::from_secs(3600) && delay >= Duration::from_secs(3590),
1576            "expected ~3600s delay, got {delay:?}"
1577        );
1578    }
1579
1580    #[test]
1581    fn retry_delay_prefers_retry_after_only_for_429() {
1582        let config = RetryConfig {
1583            max_retries: 3,
1584            base_delay: Duration::from_millis(100),
1585        };
1586        let mut headers = HeaderMap::new();
1587        headers.insert(RETRY_AFTER, HeaderValue::from_static("5"));
1588
1589        // 429 with Retry-After honors the header.
1590        assert_eq!(
1591            retry_delay_for_response(&config, StatusCode::TOO_MANY_REQUESTS, &headers, 0),
1592            Duration::from_secs(5)
1593        );
1594        // 5xx ignores Retry-After and uses jittered backoff, bounded by the
1595        // unjittered upper bound (see `backoff_is_exponential_and_capped`).
1596        assert!(
1597            retry_delay_for_response(&config, StatusCode::BAD_GATEWAY, &headers, 1)
1598                <= Duration::from_millis(200)
1599        );
1600        // 429 without Retry-After falls back to jittered backoff, same bound.
1601        let empty = HeaderMap::new();
1602        assert!(
1603            retry_delay_for_response(&config, StatusCode::TOO_MANY_REQUESTS, &empty, 2)
1604                <= Duration::from_millis(400)
1605        );
1606    }
1607
1608    #[test]
1609    fn retry_delay_for_response_caps_large_retry_after() {
1610        // The internal retry-loop call site must still cap a large
1611        // Retry-After, even though the shared parser itself is uncapped.
1612        let config = RetryConfig {
1613            max_retries: 3,
1614            base_delay: Duration::from_millis(100),
1615        };
1616        let mut headers = HeaderMap::new();
1617        headers.insert(RETRY_AFTER, HeaderValue::from_static("9999"));
1618        assert_eq!(
1619            retry_delay_for_response(&config, StatusCode::TOO_MANY_REQUESTS, &headers, 0),
1620            RETRY_MAX_DELAY
1621        );
1622    }
1623
1624    // --- Pagination (issue #88) --------------------------------------------
1625
1626    /// A minimal `page`/`limit`/`total`/`data` envelope for exercising
1627    /// [`consume_page`] / [`starting_page`] without a real generated response
1628    /// type.
1629    #[derive(Debug)]
1630    struct DummyPage {
1631        page: i64,
1632        limit: i64,
1633        total: Option<i64>,
1634        data: Vec<i32>,
1635    }
1636
1637    impl Paginated for DummyPage {
1638        type Item = i32;
1639
1640        fn page(&self) -> i64 {
1641            self.page
1642        }
1643
1644        fn limit(&self) -> i64 {
1645            self.limit
1646        }
1647
1648        fn total(&self) -> Option<i64> {
1649            self.total
1650        }
1651
1652        fn into_items(self) -> Vec<i32> {
1653            self.data
1654        }
1655    }
1656
1657    #[test]
1658    fn starting_page_defaults_to_one_when_absent_or_invalid() {
1659        assert_eq!(starting_page(&BTreeMap::new()), 1);
1660
1661        let mut params = BTreeMap::new();
1662        params.insert("page".to_string(), "not-a-number".to_string());
1663        assert_eq!(starting_page(&params), 1);
1664
1665        params.insert("page".to_string(), "0".to_string());
1666        assert_eq!(starting_page(&params), 1);
1667
1668        params.insert("page".to_string(), "-1".to_string());
1669        assert_eq!(starting_page(&params), 1);
1670    }
1671
1672    #[test]
1673    fn starting_page_honors_explicit_positive_page() {
1674        let mut params = BTreeMap::new();
1675        params.insert("page".to_string(), "5".to_string());
1676        assert_eq!(starting_page(&params), 5);
1677    }
1678
1679    #[test]
1680    fn consume_page_continues_while_below_total() {
1681        let mut next_page = 1;
1682        let mut done = false;
1683        let page = DummyPage {
1684            page: 1,
1685            limit: 2,
1686            total: Some(5),
1687            data: vec![1, 2],
1688        };
1689
1690        let items = consume_page(&mut next_page, &mut done, page);
1691
1692        assert_eq!(items, Some(vec![1, 2]));
1693        assert_eq!(next_page, 2);
1694        assert!(!done);
1695    }
1696
1697    #[test]
1698    fn consume_page_terminates_when_total_reached() {
1699        let mut next_page = 2;
1700        let mut done = false;
1701        // page * limit == 4 >= total (3): last page.
1702        let page = DummyPage {
1703            page: 2,
1704            limit: 2,
1705            total: Some(3),
1706            data: vec![3],
1707        };
1708
1709        let items = consume_page(&mut next_page, &mut done, page);
1710
1711        assert_eq!(items, Some(vec![3]));
1712        assert!(done, "reaching total must mark the paginator done");
1713    }
1714
1715    #[test]
1716    fn consume_page_terminates_on_empty_data_regardless_of_total() {
1717        let mut next_page = 2;
1718        let mut done = false;
1719        // total (100) not yet reached, but an empty page still terminates.
1720        let page = DummyPage {
1721            page: 2,
1722            limit: 1,
1723            total: Some(100),
1724            data: Vec::<i32>::new(),
1725        };
1726
1727        let items = consume_page(&mut next_page, &mut done, page);
1728
1729        assert_eq!(items, None);
1730        assert!(done);
1731    }
1732
1733    #[test]
1734    fn consume_page_without_total_relies_on_empty_page() {
1735        let mut next_page = 1;
1736        let mut done = false;
1737        let page = DummyPage {
1738            page: 1,
1739            limit: 10,
1740            total: None,
1741            data: vec![1],
1742        };
1743
1744        let items = consume_page(&mut next_page, &mut done, page);
1745
1746        assert_eq!(items, Some(vec![1]));
1747        assert!(
1748            !done,
1749            "without a reported total, only an empty page should terminate"
1750        );
1751    }
1752}