Skip to main content

pakasir_sdk/
client.rs

1// Copyright 2026 H0llyW00dzZ
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! HTTP transport for the SDK.
16//!
17//! [`Client`] holds the merchant credentials, the underlying
18//! [`reqwest::Client`], and the policy knobs (retries, backoff, response size
19//! limit, language). Service types in [`crate::transaction`] and
20//! [`crate::simulation`] wrap a `Client` and call [`Client::do_request`].
21//!
22//! Retry behavior:
23//!
24//! - `429`, `502`, `503`, `504` are retried.
25//! - `500` is not retried; it usually means a server bug, not a transient
26//!   condition.
27//! - Transport errors are retried unless they come from invalid builder
28//!   configuration.
29//! - `Retry-After` (seconds or HTTP-date) is honored and capped at
30//!   [`DEFAULT_RETRY_WAIT_MAX`] (or whatever the builder sets).
31//!
32//! Use [`Client::builder`] to configure a client.
33
34use rand::random;
35use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderValue, RETRY_AFTER};
36use reqwest::{Method, Response, Url};
37use std::time::Duration;
38
39use crate::constants::user_agent;
40use crate::error::{BoxError, Error, Result};
41use crate::i18n::Language;
42#[cfg(feature = "qr")]
43use crate::qr::{Options as QrOptions, QrGenerator};
44
45/// Production base URL.
46pub const DEFAULT_BASE_URL: &str = "https://app.pakasir.com";
47/// Per-request timeout used when the SDK builds its own [`reqwest::Client`].
48pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
49/// Number of additional attempts after the first one fails.
50pub const DEFAULT_RETRIES: usize = 3;
51/// Minimum sleep between retries.
52pub const DEFAULT_RETRY_WAIT_MIN: Duration = Duration::from_secs(1);
53/// Maximum sleep between retries. Also the ceiling for any `Retry-After`
54/// hint coming from the server.
55pub const DEFAULT_RETRY_WAIT_MAX: Duration = Duration::from_secs(30);
56/// Cap on a single response body, in bytes (1 MiB).
57///
58/// Anything larger is rejected with [`Error::ResponseTooLarge`] before being
59/// buffered.
60pub const DEFAULT_MAX_RESPONSE_SIZE: usize = 1 << 20;
61
62/// Async HTTP client for the Pakasir REST API.
63///
64/// Cloning a `Client` is cheap (the inner [`reqwest::Client`] is reference
65/// counted) and safe across tasks. Build one with [`Client::new`] or
66/// [`Client::builder`] and pass it into the service types.
67#[derive(Debug, Clone)]
68pub struct Client {
69    project: String,
70    api_key: String,
71    base_url: String,
72    http_client: reqwest::Client,
73    language: Language,
74    retries: usize,
75    retry_wait_min: Duration,
76    retry_wait_max: Duration,
77    max_response_size: usize,
78    #[cfg(feature = "qr")]
79    qr: QrGenerator,
80}
81
82/// Builder for [`Client`].
83///
84/// Returned by [`Client::builder`]. Setters consume and return `self`; finish
85/// with [`ClientBuilder::build`].
86#[derive(Debug, Clone)]
87pub struct ClientBuilder {
88    project: String,
89    api_key: String,
90    base_url: String,
91    http_client: Option<reqwest::Client>,
92    timeout: Duration,
93    language: Language,
94    retries: usize,
95    retry_wait_min: Duration,
96    retry_wait_max: Duration,
97    max_response_size: usize,
98    #[cfg(feature = "qr")]
99    qr_options: QrOptions,
100}
101
102/// Outcome of a single request attempt.
103///
104/// `Stop` means we are done and the caller should see this error.
105/// `Retry` carries the underlying error and an optional `Retry-After` hint
106/// so the loop can wait the right amount before trying again.
107enum AttemptError {
108    Stop(Error),
109    Retry {
110        source: BoxError,
111        retry_after_hint: Option<Duration>,
112    },
113}
114
115impl Client {
116    /// Build a client with default settings.
117    ///
118    /// Same as `Client::builder(project, api_key).build()`. Credential
119    /// validation is deferred until the first [`Client::do_request`] call,
120    /// so this never fails.
121    pub fn new(project: impl Into<String>, api_key: impl Into<String>) -> Self {
122        Self::builder(project, api_key).build()
123    }
124
125    /// Start a [`ClientBuilder`].
126    pub fn builder(project: impl Into<String>, api_key: impl Into<String>) -> ClientBuilder {
127        ClientBuilder::new(project, api_key)
128    }
129
130    /// Configured project slug.
131    pub fn project(&self) -> &str {
132        &self.project
133    }
134
135    /// Configured API key.
136    pub fn api_key(&self) -> &str {
137        &self.api_key
138    }
139
140    /// Language used when formatting localized error messages.
141    pub fn language(&self) -> Language {
142        self.language
143    }
144
145    /// Borrow the QR generator attached to this client.
146    ///
147    /// Available only when the `qr` feature is enabled.
148    #[cfg(feature = "qr")]
149    pub fn qr(&self) -> &QrGenerator {
150        &self.qr
151    }
152
153    /// Send a request to the API.
154    ///
155    /// This is the low-level entry point used by the service modules. It
156    /// validates credentials, builds the URL from `base_url + path`, applies
157    /// retry / backoff / `Retry-After`, enforces the response size limit,
158    /// and turns non-success responses into [`Error::Api`].
159    ///
160    /// The returned bytes are the raw response body; JSON decoding is left
161    /// to the caller.
162    pub async fn do_request(
163        &self,
164        method: Method,
165        path: &str,
166        body: Option<Vec<u8>>,
167    ) -> Result<Vec<u8>> {
168        self.validate_credentials()?;
169
170        let mut last_error: Option<BoxError> = None;
171        let mut retry_after_hint = None;
172
173        for attempt in 0..=self.retries {
174            self.wait_for_retry(attempt, retry_after_hint).await;
175
176            match self
177                .execute_attempt(method.clone(), path, body.as_deref())
178                .await
179            {
180                Ok(bytes) => return Ok(bytes),
181                Err(AttemptError::Stop(error)) => return Err(error),
182                Err(AttemptError::Retry {
183                    source,
184                    retry_after_hint: hint,
185                }) => {
186                    last_error = Some(source);
187                    retry_after_hint = hint;
188                }
189            }
190        }
191
192        let source: BoxError = last_error
193            .unwrap_or_else(|| Box::new(std::io::Error::other("request failed")) as BoxError);
194        Err(Error::request_failed_after_retries(
195            self.language,
196            self.retries,
197            source,
198        ))
199    }
200
201    /// Reject empty credentials before any network call is made.
202    fn validate_credentials(&self) -> Result<()> {
203        if self.project.is_empty() {
204            return Err(Error::invalid_project(self.language));
205        }
206        if self.api_key.is_empty() {
207            return Err(Error::invalid_api_key(self.language));
208        }
209        Ok(())
210    }
211
212    /// Run one HTTP attempt and classify the result.
213    ///
214    /// Errors come back as [`AttemptError::Stop`] (do not retry) or
215    /// [`AttemptError::Retry`] (retry, optionally honoring a `Retry-After`
216    /// hint from the response).
217    async fn execute_attempt(
218        &self,
219        method: Method,
220        path: &str,
221        body: Option<&[u8]>,
222    ) -> std::result::Result<Vec<u8>, AttemptError> {
223        let url = self.build_url(path).map_err(AttemptError::Stop)?;
224
225        let mut request = self
226            .http_client
227            .request(method, url)
228            .header(ACCEPT, HeaderValue::from_static("application/json"));
229
230        if let Some(body) = body {
231            request = request
232                .header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
233                .body(body.to_vec());
234        }
235
236        let response = request.send().await.map_err(|err| {
237            if is_retryable_transport(&err) {
238                AttemptError::Retry {
239                    source: Box::new(err),
240                    retry_after_hint: None,
241                }
242            } else {
243                AttemptError::Stop(Error::request_failed(self.language, Box::new(err)))
244            }
245        })?;
246
247        self.handle_response(response).await
248    }
249
250    /// Read the body (subject to [`Client::max_response_size`]), look at the
251    /// status, and decide between success, permanent failure, and retry.
252    async fn handle_response(
253        &self,
254        response: Response,
255    ) -> std::result::Result<Vec<u8>, AttemptError> {
256        let status = response.status();
257        let retry_after_hint = parse_retry_after(response.headers().get(RETRY_AFTER));
258
259        let body = self
260            .read_response_body(response)
261            .await
262            .map_err(|err| match err {
263                Error::ResponseTooLarge { .. } => {
264                    AttemptError::Stop(Error::request_failed(self.language, Box::new(err)))
265                }
266                other => AttemptError::Retry {
267                    source: Box::new(other),
268                    retry_after_hint: None,
269                },
270            })?;
271
272        if status.is_success() {
273            return Ok(body);
274        }
275
276        let api_error = Error::Api {
277            status,
278            body: String::from_utf8_lossy(&body).into_owned(),
279        };
280
281        if is_retryable_status(status) {
282            return Err(AttemptError::Retry {
283                source: Box::new(api_error),
284                retry_after_hint,
285            });
286        }
287
288        Err(AttemptError::Stop(api_error))
289    }
290
291    /// Read the response body in chunks. Bail out with
292    /// [`Error::ResponseTooLarge`] as soon as the running total exceeds
293    /// [`Client::max_response_size`], so an oversized payload is never fully
294    /// buffered.
295    async fn read_response_body(&self, mut response: Response) -> Result<Vec<u8>> {
296        let mut body = Vec::new();
297
298        while let Some(chunk) = response
299            .chunk()
300            .await
301            .map_err(|err| Error::request_failed(self.language, Box::new(err)))?
302        {
303            body.extend_from_slice(&chunk);
304            if body.len() > self.max_response_size {
305                return Err(Error::ResponseTooLarge {
306                    limit: self.max_response_size,
307                });
308            }
309        }
310
311        Ok(body)
312    }
313
314    /// Sleep before the next attempt.
315    ///
316    /// The first iteration (`attempt == 0`) returns immediately. After that,
317    /// a `Retry-After` hint wins (clamped to [`Client::retry_wait_max`]) and
318    /// otherwise we fall back to [`Client::calculate_backoff`].
319    async fn wait_for_retry(&self, attempt: usize, retry_after_hint: Option<Duration>) {
320        if attempt == 0 {
321            return;
322        }
323
324        let wait = retry_after_hint
325            .map(|hint| hint.min(self.retry_wait_max))
326            .unwrap_or_else(|| self.calculate_backoff(attempt));
327
328        tokio::time::sleep(wait).await;
329    }
330
331    /// Jittered exponential backoff bounded by
332    /// `[retry_wait_min, retry_wait_max]`.
333    ///
334    /// The multiplier doubles each attempt (`1, 2, 4, …`) and the resulting
335    /// window is randomized so concurrent callers don't retry in lockstep.
336    fn calculate_backoff(&self, attempt: usize) -> Duration {
337        let multiplier = 1u32
338            .checked_shl((attempt.saturating_sub(1)) as u32)
339            .unwrap_or(u32::MAX);
340        let max_wait = self
341            .retry_wait_min
342            .saturating_mul(multiplier)
343            .min(self.retry_wait_max);
344
345        if max_wait <= self.retry_wait_min {
346            return self.retry_wait_min;
347        }
348
349        let span_nanos = max_wait
350            .saturating_sub(self.retry_wait_min)
351            .as_nanos()
352            .min(u64::MAX as u128) as u64;
353        let jitter = random::<u64>() % (span_nanos + 1);
354        self.retry_wait_min + Duration::from_nanos(jitter)
355    }
356
357    /// Join the base URL and `path` and parse the result.
358    ///
359    /// Returns [`Error::BuildRequest`] on a parse failure.
360    fn build_url(&self, path: &str) -> Result<Url> {
361        Url::parse(&format!("{}{}", self.base_url, path))
362            .map_err(|source| Error::BuildRequest { source })
363    }
364}
365
366impl ClientBuilder {
367    /// New builder with all defaults applied. Most callers should use
368    /// [`Client::builder`] instead.
369    pub fn new(project: impl Into<String>, api_key: impl Into<String>) -> Self {
370        Self {
371            project: project.into(),
372            api_key: api_key.into(),
373            base_url: DEFAULT_BASE_URL.to_owned(),
374            http_client: None,
375            timeout: DEFAULT_TIMEOUT,
376            language: Language::English,
377            retries: DEFAULT_RETRIES,
378            retry_wait_min: DEFAULT_RETRY_WAIT_MIN,
379            retry_wait_max: DEFAULT_RETRY_WAIT_MAX,
380            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
381            #[cfg(feature = "qr")]
382            qr_options: QrOptions::default(),
383        }
384    }
385
386    /// Override the API base URL. Trailing slashes are stripped so endpoint
387    /// paths never end up with `//` in them.
388    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
389        self.base_url = base_url.into().trim_end_matches('/').to_owned();
390        self
391    }
392
393    /// Use a custom [`reqwest::Client`] (shared pool, proxy, custom TLS, …).
394    /// When set, the [`ClientBuilder::timeout`] value is ignored — configure
395    /// the timeout on the client you pass in.
396    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
397        self.http_client = Some(http_client);
398        self
399    }
400
401    /// Per-request timeout for the SDK's default HTTP client. Zero is
402    /// ignored so the default is kept.
403    pub fn timeout(mut self, timeout: Duration) -> Self {
404        if !timeout.is_zero() {
405            self.timeout = timeout;
406        }
407        self
408    }
409
410    /// Language used for localized error messages.
411    pub fn language(mut self, language: Language) -> Self {
412        self.language = language;
413        self
414    }
415
416    /// Number of retry attempts after the first one. `0` disables retries.
417    pub fn retries(mut self, retries: usize) -> Self {
418        self.retries = retries;
419        self
420    }
421
422    /// Set the backoff bounds.
423    ///
424    /// Zero durations are clamped to 1 ms. If `min > max` the two are
425    /// swapped so the resulting interval is always sane.
426    pub fn retry_wait(mut self, min: Duration, max: Duration) -> Self {
427        let floor = Duration::from_millis(1);
428        let mut resolved_min = if min.is_zero() { floor } else { min };
429        let mut resolved_max = if max.is_zero() { floor } else { max };
430
431        if resolved_min > resolved_max {
432            std::mem::swap(&mut resolved_min, &mut resolved_max);
433        }
434
435        self.retry_wait_min = resolved_min;
436        self.retry_wait_max = resolved_max;
437        self
438    }
439
440    /// Maximum response body size in bytes. Zero is ignored.
441    pub fn max_response_size(mut self, max_response_size: usize) -> Self {
442        if max_response_size > 0 {
443            self.max_response_size = max_response_size;
444        }
445        self
446    }
447
448    /// QR generator settings exposed through [`Client::qr`].
449    ///
450    /// Available only when the `qr` feature is enabled.
451    #[cfg(feature = "qr")]
452    pub fn qr_options(mut self, qr_options: QrOptions) -> Self {
453        self.qr_options = qr_options;
454        self
455    }
456
457    /// Finalize the builder.
458    ///
459    /// If no custom [`reqwest::Client`] was supplied, the default one is
460    /// built with the configured timeout and the SDK user-agent. A failure
461    /// at this point would be a bug in the library, so it panics.
462    pub fn build(self) -> Client {
463        let http_client = self.http_client.unwrap_or_else(|| {
464            reqwest::Client::builder()
465                .timeout(self.timeout)
466                .user_agent(user_agent())
467                .build()
468                .expect("default reqwest client configuration must be valid")
469        });
470
471        Client {
472            project: self.project,
473            api_key: self.api_key,
474            base_url: self.base_url,
475            http_client,
476            language: self.language,
477            retries: self.retries,
478            retry_wait_min: self.retry_wait_min,
479            retry_wait_max: self.retry_wait_max,
480            max_response_size: self.max_response_size,
481            #[cfg(feature = "qr")]
482            qr: QrGenerator::new(self.qr_options),
483        }
484    }
485}
486
487/// HTTP statuses the SDK treats as transient: `429`, `502`, `503`, `504`.
488///
489/// `500` is left out on purpose. It usually means a deterministic server
490/// bug, not something a retry will fix.
491fn is_retryable_status(status: reqwest::StatusCode) -> bool {
492    matches!(
493        status,
494        reqwest::StatusCode::TOO_MANY_REQUESTS
495            | reqwest::StatusCode::BAD_GATEWAY
496            | reqwest::StatusCode::SERVICE_UNAVAILABLE
497            | reqwest::StatusCode::GATEWAY_TIMEOUT
498    )
499}
500
501/// Transport errors worth retrying.
502///
503/// Builder errors mean the request was never going to be valid in the first
504/// place, so we don't retry those. Anything else (connect, TLS, body
505/// stream, …) is treated as transient.
506fn is_retryable_transport(error: &reqwest::Error) -> bool {
507    !error.is_builder()
508}
509
510/// Parse a `Retry-After` header into a [`Duration`].
511///
512/// Both forms from RFC 7231 are supported:
513///
514/// - **delta-seconds** – integer seconds, capped at 24h to keep the
515///   resulting [`Duration`] in range.
516/// - **HTTP-date** – parsed with [`httpdate`] and converted to a duration
517///   relative to [`std::time::SystemTime::now`].
518///
519/// Returns `None` when the header is missing, empty, or unparseable.
520fn parse_retry_after(value: Option<&HeaderValue>) -> Option<Duration> {
521    let raw = value?.to_str().ok()?.trim();
522    if raw.is_empty() {
523        return None;
524    }
525
526    if let Ok(seconds) = raw.parse::<u64>() {
527        return Some(Duration::from_secs(seconds.min(86_400)));
528    }
529
530    let parsed = httpdate::parse_http_date(raw).ok()?;
531    parsed.duration_since(std::time::SystemTime::now()).ok()
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use reqwest::StatusCode;
538
539    #[test]
540    fn client_new_yields_same_defaults_as_builder() {
541        let a = Client::new("p", "k");
542        let b = Client::builder("p", "k").build();
543        assert_eq!(a.project(), b.project());
544        assert_eq!(a.api_key(), b.api_key());
545        assert_eq!(a.language(), b.language());
546        assert_eq!(a.retries, b.retries);
547        assert_eq!(a.retry_wait_min, b.retry_wait_min);
548        assert_eq!(a.retry_wait_max, b.retry_wait_max);
549        assert_eq!(a.max_response_size, b.max_response_size);
550        assert_eq!(a.base_url, b.base_url);
551    }
552
553    #[test]
554    fn client_getters_return_configured_values() {
555        let client = Client::builder("proj", "key").build();
556        assert_eq!(client.project(), "proj");
557        assert_eq!(client.api_key(), "key");
558        assert_eq!(client.language(), Language::English);
559    }
560
561    #[test]
562    fn builder_base_url_strips_trailing_slashes() {
563        let client = Client::builder("p", "k").base_url("https://x/").build();
564        assert_eq!(client.base_url, "https://x");
565
566        let client = Client::builder("p", "k").base_url("https://x///").build();
567        assert_eq!(client.base_url, "https://x");
568    }
569
570    #[test]
571    fn builder_http_client_swaps_underlying_reqwest_client() {
572        let custom = reqwest::Client::builder().build().unwrap();
573        // No public way to identify the inner client, but build() must not
574        // panic and the resulting Client must be usable.
575        let _ = Client::builder("p", "k").http_client(custom).build();
576    }
577
578    #[test]
579    fn builder_timeout_zero_is_a_no_op() {
580        let client = Client::builder("p", "k").timeout(Duration::ZERO).build();
581        // The Client doesn't expose its timeout; we just verify the builder
582        // chain executes the no-op branch without panicking.
583        let _ = client;
584    }
585
586    #[test]
587    fn builder_timeout_applies_positive_durations() {
588        let _client = Client::builder("p", "k")
589            .timeout(Duration::from_secs(7))
590            .build();
591    }
592
593    #[test]
594    fn builder_language_overrides_default() {
595        let client = Client::builder("p", "k")
596            .language(Language::Indonesian)
597            .build();
598        assert_eq!(client.language(), Language::Indonesian);
599    }
600
601    #[test]
602    fn builder_retries_overrides_default() {
603        let client = Client::builder("p", "k").retries(7).build();
604        assert_eq!(client.retries, 7);
605
606        let client = Client::builder("p", "k").retries(0).build();
607        assert_eq!(client.retries, 0);
608    }
609
610    #[test]
611    fn builder_retry_wait_zero_durations_clamp_to_one_millisecond() {
612        let client = Client::builder("p", "k")
613            .retry_wait(Duration::ZERO, Duration::ZERO)
614            .build();
615        assert_eq!(client.retry_wait_min, Duration::from_millis(1));
616        assert_eq!(client.retry_wait_max, Duration::from_millis(1));
617    }
618
619    #[test]
620    fn builder_retry_wait_swaps_min_and_max_when_inverted() {
621        let client = Client::builder("p", "k")
622            .retry_wait(Duration::from_secs(10), Duration::from_secs(1))
623            .build();
624        // 10s > 1s → should be swapped so min < max.
625        assert_eq!(client.retry_wait_min, Duration::from_secs(1));
626        assert_eq!(client.retry_wait_max, Duration::from_secs(10));
627    }
628
629    #[test]
630    fn builder_retry_wait_keeps_already_ordered_pair() {
631        let client = Client::builder("p", "k")
632            .retry_wait(Duration::from_millis(100), Duration::from_millis(500))
633            .build();
634        assert_eq!(client.retry_wait_min, Duration::from_millis(100));
635        assert_eq!(client.retry_wait_max, Duration::from_millis(500));
636    }
637
638    #[test]
639    fn builder_max_response_size_zero_is_a_no_op() {
640        let client = Client::builder("p", "k").max_response_size(0).build();
641        assert_eq!(client.max_response_size, DEFAULT_MAX_RESPONSE_SIZE);
642    }
643
644    #[test]
645    fn builder_max_response_size_overrides_default() {
646        let client = Client::builder("p", "k").max_response_size(512).build();
647        assert_eq!(client.max_response_size, 512);
648    }
649
650    #[cfg(feature = "qr")]
651    #[test]
652    fn builder_qr_options_propagate_to_client() {
653        use crate::qr::{Options as QrOpts, RecoveryLevel};
654        let opts = QrOpts::default()
655            .with_size(384)
656            .with_recovery_level(RecoveryLevel::High);
657        let client = Client::builder("p", "k").qr_options(opts.clone()).build();
658        assert_eq!(client.qr().options(), &opts);
659    }
660
661    #[test]
662    fn is_retryable_status_matches_documented_set() {
663        assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
664        assert!(is_retryable_status(StatusCode::BAD_GATEWAY));
665        assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE));
666        assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT));
667    }
668
669    #[test]
670    fn is_retryable_status_excludes_other_statuses() {
671        for code in [
672            StatusCode::OK,
673            StatusCode::BAD_REQUEST,
674            StatusCode::NOT_FOUND,
675            StatusCode::UNAUTHORIZED,
676            StatusCode::INTERNAL_SERVER_ERROR,
677            StatusCode::NOT_IMPLEMENTED,
678        ] {
679            assert!(!is_retryable_status(code), "must NOT retry on {code}");
680        }
681    }
682
683    #[test]
684    fn parse_retry_after_returns_none_for_missing_header() {
685        assert!(parse_retry_after(None).is_none());
686    }
687
688    #[test]
689    fn parse_retry_after_returns_none_for_empty_value() {
690        let header = HeaderValue::from_static("");
691        assert!(parse_retry_after(Some(&header)).is_none());
692
693        let header = HeaderValue::from_static("   ");
694        assert!(parse_retry_after(Some(&header)).is_none());
695    }
696
697    #[test]
698    fn parse_retry_after_returns_none_for_unparseable_value() {
699        let header = HeaderValue::from_static("not a real value");
700        assert!(parse_retry_after(Some(&header)).is_none());
701    }
702
703    #[test]
704    fn parse_retry_after_parses_delta_seconds() {
705        let header = HeaderValue::from_static("12");
706        assert_eq!(
707            parse_retry_after(Some(&header)),
708            Some(Duration::from_secs(12))
709        );
710    }
711
712    #[test]
713    fn parse_retry_after_caps_delta_seconds_at_24h() {
714        let header = HeaderValue::from_static("999999"); // way more than 24h
715        assert_eq!(
716            parse_retry_after(Some(&header)),
717            Some(Duration::from_secs(86_400))
718        );
719    }
720
721    #[test]
722    fn parse_retry_after_parses_http_date_in_the_future() {
723        // Build a date a long way in the future so the duration is positive
724        // regardless of clock skew.
725        let target = std::time::SystemTime::now() + Duration::from_secs(60);
726        let formatted = httpdate::fmt_http_date(target);
727        let header = HeaderValue::from_str(&formatted).unwrap();
728        let parsed = parse_retry_after(Some(&header)).expect("future HTTP-date should parse");
729        // HTTP-date only carries second precision, so allow a small window.
730        assert!(parsed <= Duration::from_secs(61));
731    }
732
733    #[test]
734    fn parse_retry_after_returns_none_for_http_date_in_the_past() {
735        let header = HeaderValue::from_static("Wed, 21 Oct 1970 07:28:00 GMT");
736        assert!(parse_retry_after(Some(&header)).is_none());
737    }
738
739    #[test]
740    fn calculate_backoff_floors_at_retry_wait_min() {
741        let client = Client::builder("p", "k")
742            .retry_wait(Duration::from_millis(10), Duration::from_millis(20))
743            .build();
744        // attempt = 0 → max_wait == retry_wait_min → return min directly
745        assert_eq!(client.calculate_backoff(0), Duration::from_millis(10));
746    }
747
748    #[test]
749    fn calculate_backoff_stays_within_configured_bounds() {
750        let client = Client::builder("p", "k")
751            .retry_wait(Duration::from_millis(10), Duration::from_millis(20))
752            .build();
753        for attempt in 1..=5_usize {
754            let wait = client.calculate_backoff(attempt);
755            assert!(
756                wait >= Duration::from_millis(10),
757                "attempt={attempt} wait={wait:?}"
758            );
759            assert!(
760                wait <= Duration::from_millis(20),
761                "attempt={attempt} wait={wait:?}"
762            );
763        }
764    }
765
766    #[test]
767    fn calculate_backoff_handles_extreme_attempt_count() {
768        let client = Client::builder("p", "k")
769            .retry_wait(Duration::from_millis(10), Duration::from_secs(30))
770            .build();
771        // Guard against shift overflow on huge attempt counts.
772        let wait = client.calculate_backoff(64);
773        assert!(wait <= Duration::from_secs(30));
774    }
775
776    #[tokio::test]
777    async fn do_request_rejects_empty_project() {
778        let client = Client::builder("", "k").retries(0).build();
779        let err = client
780            .do_request(Method::GET, "/x", None)
781            .await
782            .unwrap_err();
783        assert!(matches!(err, Error::InvalidProject { .. }));
784    }
785
786    #[tokio::test]
787    async fn do_request_rejects_empty_api_key() {
788        let client = Client::builder("p", "").retries(0).build();
789        let err = client
790            .do_request(Method::GET, "/x", None)
791            .await
792            .unwrap_err();
793        assert!(matches!(err, Error::InvalidApiKey { .. }));
794    }
795
796    #[tokio::test]
797    async fn do_request_surfaces_build_url_failures() {
798        // Configure a base URL that does not parse as a URL on its own.
799        // `Client::do_request` glues `base_url + path` and parses the result.
800        let client = Client::builder("p", "k")
801            .base_url("not a url")
802            .retries(0)
803            .build();
804        let err = client
805            .do_request(Method::GET, "/path", None)
806            .await
807            .unwrap_err();
808        assert!(matches!(err, Error::BuildRequest { .. }));
809    }
810
811    #[tokio::test]
812    async fn wait_for_retry_returns_immediately_on_first_attempt() {
813        let client = Client::builder("p", "k")
814            .retry_wait(Duration::from_secs(60), Duration::from_secs(60))
815            .build();
816        // Even with a huge retry_wait, attempt == 0 must not sleep.
817        let start = std::time::Instant::now();
818        client.wait_for_retry(0, None).await;
819        assert!(start.elapsed() < Duration::from_millis(500));
820    }
821
822    #[tokio::test]
823    async fn wait_for_retry_honors_retry_after_hint_clamped_to_max() {
824        let client = Client::builder("p", "k")
825            .retry_wait(Duration::from_millis(1), Duration::from_millis(5))
826            .build();
827        // Hint larger than retry_wait_max gets clamped, so this sleeps ~5ms.
828        let start = std::time::Instant::now();
829        client
830            .wait_for_retry(1, Some(Duration::from_secs(60)))
831            .await;
832        assert!(start.elapsed() < Duration::from_millis(500));
833    }
834}