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}