Skip to main content

schwab_sdk/
error.rs

1//! Crate-wide error type.
2//!
3//! Every fallible operation in `schwab-sdk` returns [`Result<T>`] aliasing
4//! `std::result::Result<T, Error>`. Variants are kept structured wherever
5//! Schwab gives us enough information; `Codec` carries a `context` string
6//! describing the operation that failed.
7//!
8//! Non-2xx HTTP responses decode into an [`ErrorBody`]. Schwab's two API
9//! families return different error envelopes - the Trader API a flat
10//! [`ServiceError`], the Market Data API a structured [`ErrorResponse`] -
11//! and [`ErrorBody`] preserves whichever arrived. A body matching neither
12//! schema is kept verbatim so the HTTP status still maps to a typed
13//! variant.
14//!
15//! [`Error::is_retryable`] and [`Error::retry_after`] are the only retry
16//! seams the crate provides. Application code can use these to wire in
17//! `backon` or another policy on top.
18//!
19//! # Examples
20//!
21//! Branch on whether a failure is worth retrying:
22//!
23//! ```no_run
24//! use schwab_sdk::{AuthToken, Error, SchwabClient};
25//!
26//! # async fn run() {
27//! let client = SchwabClient::new(AuthToken::new("token"));
28//!
29//! match client.market_data().quotes().list(["AAPL"]).send().await {
30//!     Ok(quotes) => println!("{} entries", quotes.len()),
31//!     Err(err) if err.is_retryable() => println!("transient, safe to retry: {err}"),
32//!     Err(Error::Unauthorized(_)) => println!("token rejected; refresh and retry"),
33//!     Err(err) => println!("terminal: {err}"),
34//! }
35//! # }
36//! ```
37
38use std::time::Duration;
39
40use http::StatusCode;
41use serde_with::{DisplayFromStr, PickFirst, serde_as};
42
43use crate::streamer;
44
45/// Crate result alias: `Result<T, Error>`.
46pub type Result<T> = std::result::Result<T, Error>;
47
48/// Error returned by every fallible operation in this crate.
49#[derive(Debug, thiserror::Error)]
50pub enum Error {
51    /// HTTP 401. Distinct from [`Error::Http`] so a future token-refresh
52    /// seam in `SchwabClient` has a single arm to hook.
53    #[error("unauthorized: {0}")]
54    Unauthorized(ErrorBody),
55    /// HTTP 404. Distinct from [`Error::Http`] because callers idiomatically
56    /// map "broker says no such resource" to `Ok(None)`.
57    #[error("not found: {0}")]
58    NotFound(ErrorBody),
59    /// 429 with optional Retry-After.
60    #[error("rate limited: {body}")]
61    RateLimited {
62        /// Parsed `Retry-After` header value, if Schwab sent one.
63        retry_after: Option<Duration>,
64        /// Decoded response body.
65        body: ErrorBody,
66    },
67    /// Any other non-2xx response. The status is authoritative; the body
68    /// is supplementary. `retry_after` carries the parsed `Retry-After`
69    /// header when the server sent one (the spec allows it on any 4xx /
70    /// 5xx, not only `429`); callers can read it via
71    /// [`Error::retry_after`] without matching on the variant.
72    #[error("http {status}: {body}")]
73    Http {
74        /// HTTP status from the response.
75        status: StatusCode,
76        /// Parsed `Retry-After` header value, if the server sent one.
77        retry_after: Option<Duration>,
78        /// Decoded response body.
79        body: ErrorBody,
80    },
81    /// `reqwest` transport failure (DNS, connect, TLS, body read).
82    #[error("transport: {0}")]
83    Transport(#[from] reqwest::Error),
84    /// Streamer websocket: connect, handshake, or runtime frame error.
85    #[error("websocket: {0}")]
86    WebSocket(#[from] streamer::WebSocketError),
87    /// JSON serde failure on a wire body or streamer frame. `context`
88    /// names the operation (e.g. `"decode CHART_EQUITY frame"`,
89    /// `"encode subscribe request"`).
90    #[error("codec {context}: {reason}")]
91    Codec {
92        /// Names the operation that failed (e.g. `"decode response body"`).
93        context: String,
94        /// Underlying `serde` error message.
95        reason: String,
96    },
97    /// `/userPreference` response missing a required field or carrying
98    /// an unparseable value.
99    #[error("invalid preference {field}: {reason}")]
100    InvalidPreference {
101        /// Name of the missing or unparseable field.
102        field: &'static str,
103        /// Why the field was rejected (e.g. `"missing"`, parse error text).
104        reason: String,
105    },
106    /// Schwab acked a place / replace order but the `Location` header
107    /// was absent or malformed, so the new order's id is unrecoverable.
108    #[error("order id unrecoverable: {0}")]
109    OrderIdUnrecoverable(String),
110    /// An [`crate::orders::Order`] returned by a read endpoint could not
111    /// be converted into an [`crate::orders::OrderRequest`] for a
112    /// follow-up place or replace call. The carried `reason` names the
113    /// specific shape mismatch (e.g. a leg missing its instrument, an
114    /// instrument missing its `symbol`, an unknown `assetType`). This
115    /// variant is not retryable: the response will not change on a retry.
116    #[error("order response not representable as a request: {reason}")]
117    OrderResponseNotRepresentable {
118        /// What in the response prevented the conversion.
119        reason: String,
120    },
121    /// A [`crate::TokenProvider`] failed to produce a bearer token, so no
122    /// HTTP request could be issued. The wrapped source is the
123    /// provider's own error type, type-erased; the SDK has no opinion on
124    /// whether it is transient.
125    #[error("token provider: {source}")]
126    TokenProvider {
127        /// Underlying provider error, type-erased.
128        #[source]
129        source: Box<dyn std::error::Error + Send + Sync>,
130    },
131    /// A base URL passed to [`crate::SchwabClient::with_trader_base_url`]
132    /// or [`crate::SchwabClient::with_market_data_base_url`] used a
133    /// scheme that is not permitted for the current build. Release
134    /// builds require `https://`; debug builds additionally permit
135    /// `http://` so local fixture servers (wiremock and similar) can be
136    /// wired up in tests.
137    #[error("insecure base url {url}: {reason}")]
138    InsecureBaseUrl {
139        /// The rejected URL string.
140        url: String,
141        /// Why the URL was rejected.
142        reason: String,
143    },
144}
145
146impl Error {
147    /// Build the [`Error`] for a non-2xx HTTP status, given the decoded
148    /// body and any `Retry-After` duration. The HTTP status is
149    /// authoritative for the variant; the body is supplementary, so an
150    /// unrecognized body still produces the correct status-based variant.
151    pub(crate) fn from_status(
152        status: StatusCode,
153        retry_after: Option<Duration>,
154        body: ErrorBody,
155    ) -> Error {
156        match status {
157            StatusCode::UNAUTHORIZED => Error::Unauthorized(body),
158            StatusCode::NOT_FOUND => Error::NotFound(body),
159            StatusCode::TOO_MANY_REQUESTS => Error::RateLimited { retry_after, body },
160            _ => Error::Http {
161                status,
162                retry_after,
163                body,
164            },
165        }
166    }
167
168    /// Schwab-specific retry classification. Returns `true` for transient
169    /// failures (network, 5xx, 429) where the same request can be safely
170    /// retried by the caller. Returns `false` for terminal failures
171    /// (4xx other than 429, codec errors, preference / location errors).
172    ///
173    /// `schwab-sdk` does not implement retry itself; this method exists
174    /// so downstream consumers can utilize it in their own retry logic.
175    ///
176    /// # Examples
177    ///
178    /// A minimal backoff loop honoring [`Self::retry_after`] when present.
179    /// In real code a crate such as `backon` is preferable; this shows the
180    /// seam.
181    ///
182    /// ```no_run
183    /// use std::time::Duration;
184    /// use schwab_sdk::Result;
185    ///
186    /// async fn with_retry<F, Fut, T>(mut op: F) -> Result<T>
187    /// where
188    ///     F: FnMut() -> Fut,
189    ///     Fut: std::future::Future<Output = Result<T>>,
190    /// {
191    ///     let mut attempts = 0;
192    ///     loop {
193    ///         match op().await {
194    ///             Ok(value) => return Ok(value),
195    ///             Err(err) if err.is_retryable() && attempts < 3 => {
196    ///                 attempts += 1;
197    ///                 let delay = err.retry_after().unwrap_or(Duration::from_millis(500));
198    ///                 tokio::time::sleep(delay).await;
199    ///             }
200    ///             Err(err) => return Err(err),
201    ///         }
202    ///     }
203    /// }
204    ///
205    /// # async fn caller(client: schwab_sdk::SchwabClient) -> Result<()> {
206    /// let quotes = with_retry(|| client.market_data().quotes().list(["AAPL"]).send()).await?;
207    /// # let _ = quotes;
208    /// # Ok(())
209    /// # }
210    /// ```
211    pub fn is_retryable(&self) -> bool {
212        match self {
213            Error::RateLimited { .. } => true,
214            Error::Http { status, .. } => status.is_server_error(),
215            Error::Transport(e) => e.is_timeout() || e.is_connect() || e.is_request(),
216            Error::WebSocket(e) => e.is_retryable(),
217            Error::Unauthorized(_)
218            | Error::NotFound(_)
219            | Error::Codec { .. }
220            | Error::InvalidPreference { .. }
221            | Error::OrderIdUnrecoverable(_)
222            | Error::OrderResponseNotRepresentable { .. }
223            | Error::TokenProvider { .. }
224            | Error::InsecureBaseUrl { .. } => false,
225        }
226    }
227
228    /// `Retry-After` duration parsed from a non-2xx response, when the
229    /// server sent the header. `None` for variants that do not carry
230    /// HTTP status (transport, codec, etc.) or when the header was
231    /// absent. Surfaces on both [`Error::RateLimited`] (429) and
232    /// [`Error::Http`] (any other 4xx / 5xx Schwab annotated with
233    /// `Retry-After`, typically `503`).
234    pub fn retry_after(&self) -> Option<Duration> {
235        match self {
236            Error::RateLimited { retry_after, .. } | Error::Http { retry_after, .. } => {
237                *retry_after
238            }
239            _ => None,
240        }
241    }
242}
243
244/// A decoded non-2xx response body.
245///
246/// Schwab's Trader and Market Data APIs return structurally different
247/// error envelopes; this preserves whichever shape arrived.
248///
249/// # Examples
250///
251/// ```no_run
252/// use schwab_sdk::{Error, ErrorBody};
253///
254/// # fn report(err: Error) {
255/// match err {
256///     Error::Http { status, body, .. } => match body {
257///         ErrorBody::Trader(svc) => eprintln!("{status}: {}", svc.message),
258///         ErrorBody::MarketData(resp) => eprintln!("{status}: {resp}"),
259///         ErrorBody::Unrecognized(raw) => eprintln!("{status}: {raw}"),
260///     },
261///     other => eprintln!("{other}"),
262/// }
263/// # }
264/// ```
265#[derive(Debug, Clone, PartialEq, Eq, Hash)]
266pub enum ErrorBody {
267    /// Trader API shape: a top-level message plus error strings.
268    Trader(ServiceError),
269    /// Market Data API shape: a list of structured errors.
270    MarketData(ErrorResponse),
271    /// The body matched neither family's schema; the raw text is kept for
272    /// diagnostics.
273    Unrecognized(String),
274}
275
276impl ErrorBody {
277    /// Decode a non-2xx response body.
278    ///
279    /// The two schemas are structurally disjoint: the Trader body
280    /// requires a top-level `message` string with a `Vec<String>`
281    /// `errors`, while the Market Data body has no `message` and an
282    /// `errors` array of objects. A successful decode is therefore
283    /// unambiguous. A body matching neither is returned as
284    /// [`ErrorBody::Unrecognized`].
285    pub(crate) fn parse(raw: &str) -> Self {
286        if let Ok(trader) = serde_json::from_str::<ServiceError>(raw) {
287            ErrorBody::Trader(trader)
288        } else if let Ok(market_data) = serde_json::from_str::<ErrorResponse>(raw) {
289            ErrorBody::MarketData(market_data)
290        } else {
291            ErrorBody::Unrecognized(raw.to_string())
292        }
293    }
294}
295
296impl std::fmt::Display for ErrorBody {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            ErrorBody::Trader(e) => write!(f, "{e}"),
300            ErrorBody::MarketData(e) => write!(f, "{e}"),
301            ErrorBody::Unrecognized(raw) => write!(f, "{raw}"),
302        }
303    }
304}
305
306/// The error body Schwab's Trader API returns on 4xx/5xx responses.
307#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
308#[non_exhaustive]
309pub struct ServiceError {
310    /// Human-readable summary of the failure.
311    pub message: String,
312    /// Per-field or per-rule error messages; may be empty.
313    #[serde(default)]
314    pub errors: Vec<String>,
315}
316
317impl std::fmt::Display for ServiceError {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        write!(f, "{}", self.message)?;
320        if !self.errors.is_empty() {
321            for (i, error) in self.errors.iter().enumerate() {
322                let sep = if i == 0 { ": " } else { "; " };
323                write!(f, "{sep}{error}")?;
324            }
325        }
326        Ok(())
327    }
328}
329
330/// The error body Schwab's Market Data API returns on 4xx/5xx responses:
331/// a list of structured per-error entries.
332#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq, Hash)]
333#[non_exhaustive]
334pub struct ErrorResponse {
335    /// One entry per problem Schwab detected; empty if Schwab returned
336    /// no structured detail.
337    #[serde(default)]
338    pub errors: Vec<ApiError>,
339}
340
341impl std::fmt::Display for ErrorResponse {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        if self.errors.is_empty() {
344            return write!(f, "no error detail");
345        }
346        for (i, error) in self.errors.iter().enumerate() {
347            if i > 0 {
348                write!(f, "; ")?;
349            }
350            write!(f, "{error}")?;
351        }
352        Ok(())
353    }
354}
355
356/// One structured error within an [`ErrorResponse`].
357#[serde_as]
358#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq, Hash)]
359#[non_exhaustive]
360pub struct ApiError {
361    /// Unique error id Schwab assigns; useful when contacting support.
362    #[serde(default)]
363    pub id: Option<String>,
364    /// HTTP status as Schwab echoes it in the body. Schwab is
365    /// inconsistent about sending this as a JSON string or a JSON number;
366    /// both decode here.
367    #[serde(default)]
368    #[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
369    pub status: Option<u16>,
370    /// Short error description.
371    #[serde(default)]
372    pub title: Option<String>,
373    /// Detailed error description.
374    #[serde(default)]
375    pub detail: Option<String>,
376    /// What in the request triggered the error.
377    #[serde(default)]
378    pub source: Option<ErrorSource>,
379}
380
381impl std::fmt::Display for ApiError {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match (&self.title, &self.detail) {
384            (Some(title), Some(detail)) => write!(f, "{title}: {detail}"),
385            (Some(title), None) => write!(f, "{title}"),
386            (None, Some(detail)) => write!(f, "{detail}"),
387            (None, None) => match &self.id {
388                Some(id) => write!(f, "error {id}"),
389                None => write!(f, "unspecified error"),
390            },
391        }
392    }
393}
394
395/// Locates the request element that triggered an [`ApiError`].
396#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq, Hash)]
397#[non_exhaustive]
398pub struct ErrorSource {
399    /// JSON pointer(s) into the request body.
400    #[serde(default)]
401    pub pointer: Vec<String>,
402    /// Query parameter name.
403    #[serde(default)]
404    pub parameter: Option<String>,
405    /// Header name.
406    #[serde(default)]
407    pub header: Option<String>,
408}
409
410/// Consume a non-2xx `reqwest::Response` and map it to the most specific
411/// [`Error`] variant. The body is decoded into an [`ErrorBody`]; a body
412/// that decodes as neither family's schema is preserved verbatim rather
413/// than discarded, so the status still drives the variant.
414pub(crate) async fn map_response_to_error(response: reqwest::Response) -> Error {
415    let status = response.status();
416    let retry_after = parse_retry_after(response.headers());
417    let raw = response
418        .text()
419        .await
420        .unwrap_or_else(|e| format!("<error body unavailable: {e}>"));
421    Error::from_status(status, retry_after, ErrorBody::parse(&raw))
422}
423
424fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
425    let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
426    value.parse::<u64>().ok().map(Duration::from_secs)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn trader_error_body_parses() {
435        let raw = r#"{
436            "message": "Order validation failed",
437            "errors": ["quantity must be positive", "symbol is required"]
438        }"#;
439        let ErrorBody::Trader(body) = ErrorBody::parse(raw) else {
440            panic!("expected Trader body");
441        };
442        assert_eq!(body.message, "Order validation failed");
443        assert_eq!(body.errors.len(), 2);
444        // Per-field errors must be rendered alongside the summary so an
445        // operator logging the failure sees what Schwab actually rejected,
446        // not just "Order validation failed" with no detail.
447        assert_eq!(
448            body.to_string(),
449            "Order validation failed: quantity must be positive; symbol is required"
450        );
451    }
452
453    #[test]
454    fn trader_error_body_without_errors_renders_message_only() {
455        // Empty `errors` keeps the previous one-line shape so existing
456        // log patterns matching on the bare message still work.
457        let svc = ServiceError {
458            message: "Forbidden".to_string(),
459            errors: Vec::new(),
460        };
461        assert_eq!(svc.to_string(), "Forbidden");
462    }
463
464    #[test]
465    fn trader_error_body_without_errors_array_parses() {
466        // The Trader schema marks `errors` optional; a body with only
467        // `message` must still decode rather than degrading to Codec.
468        let ErrorBody::Trader(body) = ErrorBody::parse(r#"{"message": "Forbidden"}"#) else {
469            panic!("expected Trader body");
470        };
471        assert_eq!(body.message, "Forbidden");
472        assert!(body.errors.is_empty());
473    }
474
475    #[test]
476    fn market_data_error_body_parses() {
477        // Modeled on Schwab's documented 400 response: three errors, each
478        // with a different `source` locator and a string-valued `status`.
479        let raw = r#"{
480            "errors": [
481                {
482                    "id": "6808262e-52bb-4421-9d31-6c0e762e7dd5",
483                    "status": "400",
484                    "title": "Bad Request",
485                    "detail": "Missing header",
486                    "source": { "header": "Authorization" }
487                },
488                {
489                    "id": "0be22ae7-efdf-44d9-99f4-f138049d76ca",
490                    "status": "400",
491                    "title": "Bad Request",
492                    "detail": "Search combination should have min of 1.",
493                    "source": { "pointer": ["/data/attributes/symbols", "/data/attributes/cusips"] }
494                },
495                {
496                    "id": "28485414-290f-42e2-992b-58ea3e3203b1",
497                    "status": "400",
498                    "title": "Bad Request",
499                    "detail": "valid fields should be any of all,fundamental,reference",
500                    "source": { "parameter": "fields" }
501                }
502            ]
503        }"#;
504        let ErrorBody::MarketData(body) = ErrorBody::parse(raw) else {
505            panic!("expected MarketData body");
506        };
507        assert_eq!(body.errors.len(), 3);
508
509        let first = &body.errors[0];
510        assert_eq!(first.status, Some(400));
511        assert_eq!(first.title.as_deref(), Some("Bad Request"));
512        assert_eq!(first.detail.as_deref(), Some("Missing header"));
513        assert_eq!(
514            first.source.as_ref().unwrap().header.as_deref(),
515            Some("Authorization")
516        );
517        assert_eq!(first.to_string(), "Bad Request: Missing header");
518
519        assert_eq!(body.errors[1].source.as_ref().unwrap().pointer.len(), 2);
520        assert_eq!(
521            body.errors[2].source.as_ref().unwrap().parameter.as_deref(),
522            Some("fields")
523        );
524    }
525
526    #[test]
527    fn market_data_numeric_status_parses() {
528        // Schwab's 401/404/500 examples send `status` as a bare number
529        // rather than a string; it must still decode into `u16`.
530        let raw = r#"{
531            "errors": [
532                { "id": "0be22ae7-efdf-44d9-99f4-f138049d76ca", "status": 401, "title": "Unauthorized" }
533            ]
534        }"#;
535        let ErrorBody::MarketData(body) = ErrorBody::parse(raw) else {
536            panic!("expected MarketData body");
537        };
538        assert_eq!(body.errors[0].status, Some(401));
539        assert_eq!(body.errors[0].title.as_deref(), Some("Unauthorized"));
540    }
541
542    #[test]
543    fn unrecognized_body_is_preserved() {
544        // A plain-text upstream error (e.g. a gateway timeout page) must
545        // not be discarded - the raw text is kept for diagnostics.
546        let ErrorBody::Unrecognized(raw) = ErrorBody::parse("upstream request timeout") else {
547            panic!("expected Unrecognized body");
548        };
549        assert_eq!(raw, "upstream request timeout");
550    }
551
552    #[test]
553    fn trader_and_market_data_schemas_are_disjoint() {
554        // The parse order relies on the two schemas not overlapping: a
555        // Trader body must not decode as `ErrorResponse`, and vice versa.
556        let trader = r#"{"message": "x", "errors": ["a"]}"#;
557        let market_data = r#"{"errors": [{"status": 400, "title": "Bad Request"}]}"#;
558        assert!(serde_json::from_str::<ErrorResponse>(trader).is_err());
559        assert!(serde_json::from_str::<ServiceError>(market_data).is_err());
560    }
561
562    #[test]
563    fn from_status_maps_each_documented_status() {
564        let body = || ErrorBody::Unrecognized(String::new());
565        assert!(matches!(
566            Error::from_status(StatusCode::UNAUTHORIZED, None, body()),
567            Error::Unauthorized(_)
568        ));
569        assert!(matches!(
570            Error::from_status(StatusCode::NOT_FOUND, None, body()),
571            Error::NotFound(_)
572        ));
573        assert!(matches!(
574            Error::from_status(StatusCode::TOO_MANY_REQUESTS, None, body()),
575            Error::RateLimited { .. }
576        ));
577        assert!(matches!(
578            Error::from_status(StatusCode::BAD_REQUEST, None, body()),
579            Error::Http { status, .. } if status == StatusCode::BAD_REQUEST
580        ));
581        assert!(matches!(
582            Error::from_status(StatusCode::FORBIDDEN, None, body()),
583            Error::Http { status, .. } if status == StatusCode::FORBIDDEN
584        ));
585        assert!(matches!(
586            Error::from_status(StatusCode::SERVICE_UNAVAILABLE, None, body()),
587            Error::Http { status, .. } if status == StatusCode::SERVICE_UNAVAILABLE
588        ));
589        assert!(matches!(
590            Error::from_status(StatusCode::INTERNAL_SERVER_ERROR, None, body()),
591            Error::Http { status, .. } if status == StatusCode::INTERNAL_SERVER_ERROR
592        ));
593        assert!(matches!(
594            Error::from_status(StatusCode::BAD_GATEWAY, None, body()),
595            Error::Http { status, .. } if status == StatusCode::BAD_GATEWAY
596        ));
597    }
598
599    #[test]
600    fn rate_limited_carries_retry_after_and_is_retryable() {
601        let error = Error::from_status(
602            StatusCode::TOO_MANY_REQUESTS,
603            Some(Duration::from_secs(30)),
604            ErrorBody::Unrecognized(String::new()),
605        );
606        assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));
607        assert!(error.is_retryable());
608    }
609
610    #[test]
611    fn http_503_with_retry_after_surfaces_through_accessor() {
612        // The spec allows Retry-After on any 4xx/5xx, not just 429.
613        // `Error::retry_after()` previously returned `None` for Http
614        // variants, silently dropping the server's hint. A caller wrapping
615        // the SDK in a backoff loop now receives the upstream delay.
616        let error = Error::from_status(
617            StatusCode::SERVICE_UNAVAILABLE,
618            Some(Duration::from_secs(15)),
619            ErrorBody::Unrecognized(String::new()),
620        );
621        assert!(matches!(error, Error::Http { .. }));
622        assert_eq!(error.retry_after(), Some(Duration::from_secs(15)));
623        assert!(error.is_retryable());
624    }
625
626    #[test]
627    fn http_without_retry_after_returns_none() {
628        // Absence of the header must still surface as `None`.
629        let error = Error::from_status(
630            StatusCode::INTERNAL_SERVER_ERROR,
631            None,
632            ErrorBody::Unrecognized(String::new()),
633        );
634        assert_eq!(error.retry_after(), None);
635    }
636
637    #[test]
638    fn client_errors_are_not_retryable() {
639        let body = || ErrorBody::Unrecognized(String::new());
640        assert!(!Error::from_status(StatusCode::BAD_REQUEST, None, body()).is_retryable());
641        assert!(!Error::from_status(StatusCode::NOT_FOUND, None, body()).is_retryable());
642        assert!(!Error::from_status(StatusCode::UNAUTHORIZED, None, body()).is_retryable());
643        assert!(Error::from_status(StatusCode::INTERNAL_SERVER_ERROR, None, body()).is_retryable());
644        assert!(Error::from_status(StatusCode::BAD_GATEWAY, None, body()).is_retryable());
645    }
646}