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(¶ms);
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(¶ms);
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(¶ms), 1);
1664
1665 params.insert("page".to_string(), "0".to_string());
1666 assert_eq!(starting_page(¶ms), 1);
1667
1668 params.insert("page".to_string(), "-1".to_string());
1669 assert_eq!(starting_page(¶ms), 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(¶ms), 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}