Skip to main content

ephemeris_forge/
transport.rs

1//! HTTP transport shared by every forge adapter.
2//!
3//! Retrying, timing out, paging, and mapping a status to a failure class are
4//! the same problem for Forgejo and GitHub; only the paths and payloads
5//! differ. Adapter-specific status meaning (for example, that Forgejo
6//! answers 403 on branch protection create when the rule already exists) is
7//! resolved by the one call site that needs it, by matching on the
8//! [`TransportError`] variant that call produced.
9
10use std::error::Error as StdError;
11use std::fmt;
12use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
14use ephemeris_token::Token;
15use facet::Facet;
16use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
17use reqwest::multipart::Form;
18use reqwest::{Method, RequestBuilder, Response, Url};
19use tokio::time;
20
21/// The most pages a listing may span unless [`Http::with_page_limit`] says
22/// otherwise.
23const DEFAULT_PAGE_LIMIT: usize = 1000;
24
25/// The longest wait a rate-limited request honours. A forge that asks for
26/// more makes the request fail with [`TransportError::RateLimitWaitTooLong`].
27const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(60);
28
29const RATE_LIMIT_REMAINING: HeaderName = HeaderName::from_static("x-ratelimit-remaining");
30const RATE_LIMIT_RESET: HeaderName = HeaderName::from_static("x-ratelimit-reset");
31
32/// How a retryable failure is retried before it is reported.
33#[derive(Clone, Copy, Debug)]
34pub struct RetryPolicy {
35    /// Total attempts, including the first.
36    pub attempts: u32,
37    /// Delay before the second attempt. Each later delay doubles. A rate
38    /// limit that states its own wait uses that wait instead.
39    pub initial_delay: Duration,
40}
41
42impl Default for RetryPolicy {
43    fn default() -> Self {
44        Self {
45            attempts: 4,
46            initial_delay: Duration::from_millis(250),
47        }
48    }
49}
50
51/// Shared HTTP transport for a forge's REST API.
52///
53/// `Debug` is derived, which stays safe because [`Token`] redacts itself.
54#[derive(Clone, Debug)]
55pub struct Http {
56    client: reqwest::Client,
57    base: Url,
58    token: Token,
59    retry: RetryPolicy,
60    default_headers: HeaderMap,
61    page_limit: usize,
62}
63
64/// The method and path of a request, as it appears in an error.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct RequestLabel(String);
67
68impl RequestLabel {
69    /// The label of `method` on `path`.
70    #[must_use]
71    pub fn new(method: &Method, path: &str) -> Self {
72        Self(format!("{method} {path}"))
73    }
74}
75
76impl fmt::Display for RequestLabel {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(&self.0)
79    }
80}
81
82/// Failure building the underlying HTTP client.
83#[derive(Debug, thiserror::Error)]
84#[error("cannot build the HTTP client")]
85pub struct TransportBuildError(#[from] reqwest::Error);
86
87/// Anything that stops a request from producing a usable answer.
88///
89/// Headers are never included in any variant, so a credential cannot reach a
90/// log or an error message through here.
91#[derive(Debug, thiserror::Error)]
92pub enum TransportError {
93    /// The request path does not join cleanly onto the transport base.
94    #[error("{path} does not join onto the transport base")]
95    InvalidPath {
96        /// The path passed to the request method.
97        path: String,
98        /// The failure `Url::join` reported.
99        #[source]
100        source: url::ParseError,
101    },
102    /// The request never reached the forge, or no answer came back.
103    #[error("{request} failed")]
104    Unreachable {
105        /// The method and path that were attempted.
106        request: RequestLabel,
107        /// The underlying transport failure.
108        #[source]
109        source: reqwest::Error,
110    },
111    /// The credential is missing or invalid (401).
112    #[error("{request} was rejected: the credential is missing or invalid")]
113    CredentialRejected {
114        /// The method and path that were attempted.
115        request: RequestLabel,
116    },
117    /// The addressed resource is not there (404).
118    #[error("{request} answered 404: the resource is not there")]
119    NotFound {
120        /// The method and path that were attempted.
121        request: RequestLabel,
122    },
123    /// The forge asked the caller to slow down (429, or a 403 that carries
124    /// rate limit headers).
125    #[error("{request} was rate limited")]
126    RateLimited {
127        /// The method and path that were attempted.
128        request: RequestLabel,
129        /// How long the forge asked the caller to wait, when it said.
130        wait: Option<Duration>,
131    },
132    /// The forge asked for a longer wait than this transport honours.
133    #[error("{request} was rate limited for {} s, which is too long to wait", wait.as_secs())]
134    RateLimitWaitTooLong {
135        /// The method and path that were attempted.
136        request: RequestLabel,
137        /// How long the forge asked the caller to wait.
138        wait: Duration,
139    },
140    /// The forge read the request and refused it (400, 403, or 422).
141    #[error("{request} answered {status}: {}", summarize(body))]
142    Rejected {
143        /// The method and path that were attempted.
144        request: RequestLabel,
145        /// The HTTP status the forge answered with.
146        status: u16,
147        /// The response body, summarized for the error message.
148        body: String,
149    },
150    /// A status this transport does not otherwise classify, including a
151    /// server error (5xx) that survived every retry.
152    #[error("{request} answered {status}: {}", summarize(body))]
153    Unexpected {
154        /// The method and path that were attempted.
155        request: RequestLabel,
156        /// The HTTP status the forge answered with.
157        status: u16,
158        /// The response body, summarized for the error message.
159        body: String,
160    },
161    /// A listing still had more pages after the page limit.
162    #[error("{request} did not end within {limit} pages")]
163    TooManyPages {
164        /// The method and path of the first page.
165        request: RequestLabel,
166        /// The page limit that was exceeded.
167        limit: usize,
168    },
169    /// The forge answered successfully, but the body does not decode.
170    #[error("{request} returned a body this build cannot read")]
171    Malformed {
172        /// The method and path that were attempted.
173        request: RequestLabel,
174        /// The decoding failure.
175        #[source]
176        source: Box<facet_json::DeserializeError>,
177    },
178}
179
180impl TransportError {
181    /// The request that failed, when the failure belongs to one.
182    #[must_use]
183    pub const fn request(&self) -> Option<&RequestLabel> {
184        match self {
185            Self::Unreachable { request, source: _ }
186            | Self::CredentialRejected { request }
187            | Self::NotFound { request }
188            | Self::RateLimited { request, wait: _ }
189            | Self::RateLimitWaitTooLong { request, wait: _ }
190            | Self::Rejected {
191                request,
192                status: _,
193                body: _,
194            }
195            | Self::Unexpected {
196                request,
197                status: _,
198                body: _,
199            }
200            | Self::TooManyPages { request, limit: _ }
201            | Self::Malformed { request, source: _ } => Some(request),
202            Self::InvalidPath { path: _, source: _ } => None,
203        }
204    }
205
206    /// The wait the forge asked for before a retry.
207    #[must_use]
208    pub const fn retry_after(&self) -> Option<Duration> {
209        match self {
210            Self::RateLimited { request: _, wait } => *wait,
211            Self::InvalidPath { path: _, source: _ }
212            | Self::Unreachable {
213                request: _,
214                source: _,
215            }
216            | Self::CredentialRejected { request: _ }
217            | Self::NotFound { request: _ }
218            | Self::RateLimitWaitTooLong {
219                request: _,
220                wait: _,
221            }
222            | Self::Rejected {
223                request: _,
224                status: _,
225                body: _,
226            }
227            | Self::Unexpected {
228                request: _,
229                status: _,
230                body: _,
231            }
232            | Self::TooManyPages {
233                request: _,
234                limit: _,
235            }
236            | Self::Malformed {
237                request: _,
238                source: _,
239            } => None,
240        }
241    }
242
243    /// Whether the forge may or may not have applied the request.
244    ///
245    /// That is a connection failure or a 5xx answer.
246    #[must_use]
247    pub const fn is_uncertain(&self) -> bool {
248        match self.status() {
249            Some(status) => status >= 500 && status <= 599,
250            None => matches!(
251                self,
252                Self::Unreachable {
253                    request: _,
254                    source: _
255                }
256            ),
257        }
258    }
259
260    /// The HTTP status of a failure that carries one.
261    #[must_use]
262    pub const fn status(&self) -> Option<u16> {
263        match self {
264            Self::NotFound { request: _ } => Some(404),
265            Self::Rejected {
266                status,
267                body: _,
268                request: _,
269            }
270            | Self::Unexpected {
271                status,
272                body: _,
273                request: _,
274            } => Some(*status),
275            Self::InvalidPath { path: _, source: _ }
276            | Self::Unreachable {
277                request: _,
278                source: _,
279            }
280            | Self::CredentialRejected { request: _ }
281            | Self::RateLimited {
282                request: _,
283                wait: _,
284            }
285            | Self::RateLimitWaitTooLong {
286                request: _,
287                wait: _,
288            }
289            | Self::TooManyPages {
290                request: _,
291                limit: _,
292            }
293            | Self::Malformed {
294                request: _,
295                source: _,
296            } => None,
297        }
298    }
299
300    /// Map a status, body, and the wait its headers ask for to the failure
301    /// class it represents.
302    fn from_status(
303        request: RequestLabel,
304        status: u16,
305        body: String,
306        wait: Option<Duration>,
307    ) -> Self {
308        match status {
309            401 => Self::CredentialRejected { request },
310            404 => Self::NotFound { request },
311            429 => Self::RateLimited { request, wait },
312            403 if wait.is_some() => Self::RateLimited { request, wait },
313            400 | 403 | 422 => Self::Rejected {
314                request,
315                status,
316                body,
317            },
318            _ => Self::Unexpected {
319                request,
320                status,
321                body,
322            },
323        }
324    }
325}
326
327impl Http {
328    /// Build a transport rooted at `base`. A missing trailing slash is added
329    /// so that relative request paths join under the base.
330    ///
331    /// # Errors
332    ///
333    /// Fails when the HTTP stack cannot be built.
334    pub fn new(
335        mut base: Url,
336        token: Token,
337        timeout: Duration,
338    ) -> Result<Self, TransportBuildError> {
339        if !base.path().ends_with('/') {
340            let path = format!("{}/", base.path());
341            base.set_path(&path);
342        }
343        let client = reqwest::Client::builder()
344            .user_agent(concat!("ephemeris-forge/", env!("CARGO_PKG_VERSION")))
345            .timeout(timeout)
346            .build()?;
347        Ok(Self {
348            client,
349            base,
350            token,
351            retry: RetryPolicy::default(),
352            default_headers: HeaderMap::new(),
353            page_limit: DEFAULT_PAGE_LIMIT,
354        })
355    }
356
357    /// Replace the retry policy, which keeps tests from sleeping.
358    #[must_use]
359    pub const fn with_retry(mut self, retry: RetryPolicy) -> Self {
360        self.retry = retry;
361        self
362    }
363
364    /// Replace the most pages a listing may span.
365    #[must_use]
366    pub const fn with_page_limit(mut self, page_limit: usize) -> Self {
367        self.page_limit = page_limit;
368        self
369    }
370
371    /// Add a header sent on every request, such as an adapter's required API
372    /// version header.
373    #[must_use]
374    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
375        self.default_headers.insert(name, value);
376        self
377    }
378
379    /// Join `path` onto the transport base.
380    fn join(&self, path: &str) -> Result<Url, TransportError> {
381        self.base
382            .join(path)
383            .map_err(|source| TransportError::InvalidPath {
384                path: path.to_owned(),
385                source,
386            })
387    }
388
389    /// Like [`json`](Self::json), but a 404 answers `None`.
390    ///
391    /// # Errors
392    ///
393    /// Returns any transport, status, or decoding failure except a 404.
394    pub async fn json_optional<T: for<'input> Facet<'input>>(
395        &self,
396        method: Method,
397        path: &str,
398        body: Option<&str>,
399    ) -> Result<Option<T>, TransportError> {
400        match self.json(method, path, body).await {
401            Ok(value) => Ok(Some(value)),
402            Err(TransportError::NotFound { request: _ }) => Ok(None),
403            Err(error) => Err(error),
404        }
405    }
406
407    /// Send one request and decode its body as JSON.
408    ///
409    /// # Errors
410    ///
411    /// Returns a transport, status, or decoding failure.
412    pub async fn json<T: for<'input> Facet<'input>>(
413        &self,
414        method: Method,
415        path: &str,
416        body: Option<&str>,
417    ) -> Result<T, TransportError> {
418        let text = self.send(method.clone(), path, body).await?;
419        let source_text = if text.trim().is_empty() {
420            "null"
421        } else {
422            &text
423        };
424        facet_json::from_str(source_text).map_err(|source| TransportError::Malformed {
425            request: RequestLabel::new(&method, path),
426            source: Box::new(source),
427        })
428    }
429
430    /// Read every page of a listing whose pages are plain arrays.
431    ///
432    /// See [`paged_in`](Self::paged_in).
433    ///
434    /// # Errors
435    ///
436    /// Returns a transport, status, or decoding failure from any page, or
437    /// [`TransportError::TooManyPages`].
438    pub async fn paged<T: for<'input> Facet<'input>, Q: Fn(usize) -> String>(
439        &self,
440        page_size: usize,
441        path: &str,
442        query_for_page: Q,
443    ) -> Result<Vec<T>, TransportError> {
444        self.paged_in(page_size, path, query_for_page, |values: Vec<T>| values)
445            .await
446    }
447
448    /// Read every page of a paginated listing.
449    ///
450    /// `query_for_page` builds the query for a one-based page number. It is
451    /// appended to `path` with the right separator.
452    /// `items` takes the entries out of a decoded page, which is how a
453    /// response that wraps the list in an object is read. Paging stops once a
454    /// page returns fewer than `page_size` entries or none.
455    ///
456    /// # Errors
457    ///
458    /// Returns a transport, status, or decoding failure from any page, or
459    /// [`TransportError::TooManyPages`] when the listing is still full after
460    /// the page limit.
461    pub async fn paged_in<R, T, Q, E>(
462        &self,
463        page_size: usize,
464        path: &str,
465        query_for_page: Q,
466        items: E,
467    ) -> Result<Vec<T>, TransportError>
468    where
469        R: for<'input> Facet<'input>,
470        Q: Fn(usize) -> String,
471        E: Fn(R) -> Vec<T>,
472    {
473        let separator = if path.contains('?') { '&' } else { '?' };
474        let mut all = Vec::new();
475        for page in 1..=self.page_limit {
476            let page_path = format!("{path}{separator}{}", query_for_page(page));
477            let response: R = self.json(Method::GET, &page_path, None).await?;
478            let values = items(response);
479            let complete = values.len() < page_size;
480            all.extend(values);
481            if complete {
482                return Ok(all);
483            }
484        }
485        Err(TransportError::TooManyPages {
486            request: RequestLabel::new(
487                &Method::GET,
488                &format!("{path}{separator}{}", query_for_page(1)),
489            ),
490            limit: self.page_limit,
491        })
492    }
493
494    /// Download a resource's raw bytes. Binary content is not retried.
495    ///
496    /// `accept` replaces the default `Accept` header. Redirects are followed
497    /// and reqwest drops the credential when the target is another origin.
498    ///
499    /// # Errors
500    ///
501    /// Returns a transport or status failure.
502    pub async fn bytes(
503        &self,
504        method: Method,
505        path: &str,
506        accept: Option<HeaderValue>,
507    ) -> Result<Vec<u8>, TransportError> {
508        let label = RequestLabel::new(&method, path);
509        let mut request = self.request(method, path)?;
510        if let Some(accept) = accept {
511            let mut headers = HeaderMap::new();
512            headers.insert(ACCEPT, accept);
513            request = request.headers(headers);
514        }
515        let response = Self::dispatch(request, &label).await?;
516        if response.status().is_success() {
517            response
518                .bytes()
519                .await
520                .map(|bytes| bytes.to_vec())
521                .map_err(|source| TransportError::Unreachable {
522                    request: label,
523                    source,
524                })
525        } else {
526            Err(Self::failure(label, response).await)
527        }
528    }
529
530    /// Send a multipart form. Uploads are not retried.
531    ///
532    /// # Errors
533    ///
534    /// Returns a transport or status failure.
535    pub async fn send_multipart(
536        &self,
537        method: Method,
538        path: &str,
539        form: Form,
540    ) -> Result<String, TransportError> {
541        let label = RequestLabel::new(&method, path);
542        let response = Self::dispatch(self.request(method, path)?.multipart(form), &label).await?;
543        Self::text(label, response).await
544    }
545
546    /// Send a request with a raw byte body and an explicit content type.
547    /// Uploads are not retried.
548    ///
549    /// # Errors
550    ///
551    /// Returns a transport or status failure.
552    pub async fn send_bytes(
553        &self,
554        method: Method,
555        path: &str,
556        content_type: &str,
557        bytes: Vec<u8>,
558    ) -> Result<String, TransportError> {
559        let label = RequestLabel::new(&method, path);
560        let request = self
561            .request(method, path)?
562            .header(CONTENT_TYPE, content_type)
563            .body(bytes);
564        let response = Self::dispatch(request, &label).await?;
565        Self::text(label, response).await
566    }
567
568    /// Send one request, retrying a retryable failure with a doubling delay.
569    /// A rate limit is retried for every method. A timeout, connection
570    /// failure, or 5xx is retried only for an idempotent method, because it
571    /// does not tell whether the forge applied the request.
572    ///
573    /// # Errors
574    ///
575    /// Returns the final transport or status failure once retries, if any,
576    /// are exhausted.
577    pub async fn send(
578        &self,
579        method: Method,
580        path: &str,
581        body: Option<&str>,
582    ) -> Result<String, TransportError> {
583        let mut attempt = 1;
584        let mut delay = self.retry.initial_delay;
585        loop {
586            match self.attempt(method.clone(), path, body).await {
587                Err(error) if retryable(&method, &error) && attempt < self.retry.attempts => {
588                    let wait = retry_wait(&error, delay)?;
589                    log_retry(&error, attempt);
590                    time::sleep(wait).await;
591                    delay = delay.saturating_mul(2);
592                    attempt = attempt.saturating_add(1);
593                }
594                other => return other,
595            }
596        }
597    }
598
599    /// One request, with no retry.
600    ///
601    /// Logging records the method, the path, the status, and the duration. It
602    /// never records a header, so the credential cannot reach a log from here.
603    async fn attempt(
604        &self,
605        method: Method,
606        path: &str,
607        body: Option<&str>,
608    ) -> Result<String, TransportError> {
609        let label = RequestLabel::new(&method, path);
610        let started = Instant::now();
611
612        let mut request = self.request(method.clone(), path)?;
613        if let Some(body) = body {
614            request = request
615                .header(CONTENT_TYPE, "application/json")
616                .body(body.to_owned());
617        }
618
619        let response = Self::dispatch(request, &label).await?;
620
621        tracing::debug!(
622            method = %method,
623            path = %path,
624            status = response.status().as_u16(),
625            duration_ms = started.elapsed().as_millis(),
626            "request complete"
627        );
628
629        Self::text(label, response).await
630    }
631
632    /// An authenticated request to `path` under the base.
633    fn request(&self, method: Method, path: &str) -> Result<RequestBuilder, TransportError> {
634        Ok(self
635            .client
636            .request(method, self.join(path)?)
637            .headers(self.default_headers.clone())
638            .bearer_auth(self.token.expose()))
639    }
640
641    async fn dispatch(
642        request: RequestBuilder,
643        label: &RequestLabel,
644    ) -> Result<Response, TransportError> {
645        request
646            .send()
647            .await
648            .map_err(|source| TransportError::Unreachable {
649                request: label.clone(),
650                source,
651            })
652    }
653
654    /// The body of a successful response, or the failure its status maps to.
655    async fn text(label: RequestLabel, response: Response) -> Result<String, TransportError> {
656        if response.status().is_success() {
657            Ok(response.text().await.unwrap_or_default())
658        } else {
659            Err(Self::failure(label, response).await)
660        }
661    }
662
663    async fn failure(label: RequestLabel, response: Response) -> TransportError {
664        let status = response.status().as_u16();
665        let wait = rate_limit_wait(status, response.headers(), SystemTime::now());
666        let body = response.text().await.unwrap_or_default();
667        TransportError::from_status(label, status, body, wait)
668    }
669}
670
671/// A rate limit is retried for every method, because the forge refused the
672/// request without applying it. Anything else is retried only for an
673/// idempotent method, because it does not tell whether the forge applied it.
674fn retryable(method: &Method, error: &TransportError) -> bool {
675    matches!(
676        error,
677        TransportError::RateLimited {
678            request: _,
679            wait: _
680        }
681    ) || (method.is_idempotent() && error.is_uncertain())
682}
683
684/// The wait before retrying `error`, which is the wait the forge stated or
685/// else `backoff`. A stated wait above the cap fails the request.
686fn retry_wait(error: &TransportError, backoff: Duration) -> Result<Duration, TransportError> {
687    match (error.retry_after(), error.request()) {
688        (Some(stated), Some(request)) if stated > MAX_RATE_LIMIT_WAIT => {
689            Err(TransportError::RateLimitWaitTooLong {
690                request: request.clone(),
691                wait: stated,
692            })
693        }
694        (Some(stated), _) => Ok(stated),
695        (None, _) => Ok(backoff),
696    }
697}
698
699/// How long the response asks the caller to wait before retrying.
700///
701/// `Retry-After` counts seconds. `x-ratelimit-reset` is an epoch second and
702/// only applies once `x-ratelimit-remaining` is 0. A 403 is a rate limit only
703/// when one of the two applies, because otherwise it is a permission failure.
704fn rate_limit_wait(status: u16, headers: &HeaderMap, now: SystemTime) -> Option<Duration> {
705    if status != 403 && status != 429 {
706        return None;
707    }
708    let number = |name: &HeaderName| headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok();
709    if let Some(seconds) = number(&RETRY_AFTER) {
710        return Some(Duration::from_secs(seconds));
711    }
712    if status == 403 && number(&RATE_LIMIT_REMAINING) != Some(0) {
713        return None;
714    }
715    let reset = Duration::from_secs(number(&RATE_LIMIT_RESET)?);
716    Some(reset.saturating_sub(now.duration_since(UNIX_EPOCH).unwrap_or_default()))
717}
718
719/// Log a retryable failure before the attempt that follows it.
720fn log_retry(error: &TransportError, attempt: u32) {
721    let cause: &dyn StdError = error;
722    tracing::warn!(error = cause, attempt, "request failed, retrying");
723}
724
725/// Trim a forge error body so a huge HTML page cannot flood the output.
726#[must_use]
727pub fn summarize(body: &str) -> String {
728    const LIMIT: usize = 500;
729    let trimmed = body.trim();
730    if trimmed.is_empty() {
731        return "<empty body>".to_owned();
732    }
733    let mut out: String = trimmed.chars().take(LIMIT).collect();
734    if trimmed.chars().count() > LIMIT {
735        out.push_str("...");
736    }
737    out
738}
739
740#[cfg(test)]
741mod tests {
742    use std::time::{Duration, SystemTime};
743
744    use reqwest::Method;
745    use reqwest::header::{HeaderMap, HeaderValue};
746
747    use super::{RequestLabel, RetryPolicy, TransportError, rate_limit_wait};
748
749    fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
750        let mut headers = HeaderMap::new();
751        for (name, value) in pairs {
752            headers.insert(*name, HeaderValue::from_static(value));
753        }
754        headers
755    }
756
757    #[test]
758    fn statuses_map_to_their_documented_classes() {
759        assert!(matches!(
760            TransportError::from_status(
761                RequestLabel::new(&Method::GET, "/x"),
762                401,
763                String::new(),
764                None
765            ),
766            TransportError::CredentialRejected { request: _ }
767        ));
768        assert!(matches!(
769            TransportError::from_status(
770                RequestLabel::new(&Method::GET, "/x"),
771                404,
772                String::new(),
773                None
774            ),
775            TransportError::NotFound { request: _ }
776        ));
777        assert!(matches!(
778            TransportError::from_status(
779                RequestLabel::new(&Method::GET, "/x"),
780                429,
781                String::new(),
782                None
783            ),
784            TransportError::RateLimited {
785                request: _,
786                wait: _
787            }
788        ));
789        for status in [400, 403, 422] {
790            assert!(matches!(
791                TransportError::from_status(
792                    RequestLabel::new(&Method::GET, "/x"),
793                    status,
794                    String::new(),
795                    None
796                ),
797                TransportError::Rejected {
798                    request: _,
799                    status: _,
800                    body: _
801                }
802            ));
803        }
804        for status in [409, 500, 503] {
805            assert!(matches!(
806                TransportError::from_status(
807                    RequestLabel::new(&Method::GET, "/x"),
808                    status,
809                    String::new(),
810                    None
811                ),
812                TransportError::Unexpected {
813                    request: _,
814                    status: _,
815                    body: _
816                }
817            ));
818        }
819    }
820
821    #[test]
822    fn the_default_policy_matches_the_documented_contract() {
823        let policy = RetryPolicy::default();
824        assert_eq!(policy.attempts, 4);
825        assert_eq!(policy.initial_delay, Duration::from_millis(250));
826    }
827
828    #[test]
829    fn a_rate_limit_wait_comes_from_retry_after_or_the_reset_time() {
830        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
831        let wait = |status, pairs: &[(&'static str, &'static str)]| {
832            rate_limit_wait(status, &headers(pairs), now)
833        };
834        let seconds = |count| Some(Duration::from_secs(count));
835        assert_eq!(wait(429, &[("retry-after", "7")]), seconds(7));
836        assert_eq!(wait(403, &[("retry-after", "7")]), seconds(7));
837        assert_eq!(wait(429, &[("x-ratelimit-reset", "1030")]), seconds(30));
838        assert_eq!(
839            wait(
840                403,
841                &[
842                    ("x-ratelimit-remaining", "0"),
843                    ("x-ratelimit-reset", "1030")
844                ]
845            ),
846            seconds(30)
847        );
848        assert_eq!(
849            wait(
850                403,
851                &[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "900")]
852            ),
853            seconds(0)
854        );
855        assert_eq!(
856            wait(403, &[("retry-after", "7"), ("x-ratelimit-reset", "1030")]),
857            seconds(7)
858        );
859        assert_eq!(wait(429, &[]), None);
860    }
861
862    #[test]
863    fn a_403_without_rate_limit_headers_is_a_permission_failure() {
864        let now = SystemTime::UNIX_EPOCH;
865        assert_eq!(rate_limit_wait(403, &headers(&[]), now), None);
866        assert_eq!(
867            rate_limit_wait(
868                403,
869                &headers(&[("x-ratelimit-remaining", "12"), ("x-ratelimit-reset", "30")]),
870                now
871            ),
872            None
873        );
874        assert_eq!(
875            rate_limit_wait(500, &headers(&[("retry-after", "7")]), now),
876            None
877        );
878    }
879
880    #[test]
881    fn a_long_body_is_trimmed() {
882        let body = "x".repeat(2000);
883        let err =
884            TransportError::from_status(RequestLabel::new(&Method::GET, "/x"), 500, body, None);
885        assert!(err.to_string().len() < 700, "{err}");
886    }
887}