Skip to main content

jmap_base_client/
error.rs

1//! [`ClientError`] and the opaque wrapper types ([`HttpError`],
2//! [`WebSocketError`], [`InvalidHeaderValueError`], [`ParseError`],
3//! [`SerializeError`]) that hide [`reqwest`], [`tokio_tungstenite`], and
4//! the underlying JSON parser from this crate's public API.
5//!
6//! # SemVer policy
7//!
8//! `reqwest` and `tokio-tungstenite` are **private dependencies** of this
9//! crate. Their types do not appear in any public function signature,
10//! variant payload, or `From` impl. The wrapper types ([`HttpError`],
11//! [`WebSocketError`], [`InvalidHeaderValueError`]) expose a curated set of
12//! diagnostic accessors that return primitive types only, so this crate can
13//! bump the underlying transport's major version without breaking
14//! downstream callers.
15//!
16//! Internal construction goes through `pub(crate)` helpers on
17//! [`ClientError`] (`from_reqwest`, `from_ws`, `from_invalid_header`) —
18//! downstream consumers cannot construct the transport-error variants and
19//! never need to.
20//!
21//! # `serde_json` is a partially-wrapped dependency (bd:JMAP-6r7c.26)
22//!
23//! [`ParseError`] and [`SerializeError`] wrap [`serde_json::Error`] for the
24//! [`Parse`](ClientError::Parse) and [`Serialize`](ClientError::Serialize)
25//! variant payloads. The wrappers expose only primitive accessors
26//! ([`line`](ParseError::line), [`column`](ParseError::column),
27//! [`classify`](ParseError::classify) returning the workspace-local
28//! [`ParseCategory`] enum), so pattern-matching code on these variants
29//! is insulated from a future JSON-parser swap (e.g. `simd-json`,
30//! `serde_json_lenient`) — the variant payload type is stable across
31//! such a swap.
32//!
33//! The asymmetry with `HttpError` / `WebSocketError`: extension client
34//! crates (`jmap-mail-client`, `jmap-chat-client`, etc.) need to surface
35//! their own JSON parse / serialize failures as [`ClientError::Parse`]
36//! and [`ClientError::Serialize`], so this crate publishes
37//! [`ClientError::from_parse`] and [`ClientError::from_serialize`] as
38//! the construction path. Those constructors take a
39//! [`serde_json::Error`] in their signature, so `serde_json` remains a
40//! transitively-public dependency for crates that call those helpers
41//! directly. A future JSON-parser swap would deprecate
42//! `from_parse(serde_json::Error)` in favor of an analogous helper for
43//! the new parser; the variant payload type ([`ParseError`]) does not
44//! change.
45//!
46//! # Do not simplify the wrappers (bd:JMAP-6r7c.16)
47//!
48//! A future contributor reading this module may suggest "just put
49//! `reqwest::Error` in the variant — downstream users want the full
50//! `reqwest` API". That is the wrong simplification. The wrapper-types
51//! pattern is load-bearing for five independent reasons; all five must
52//! be re-derived from first principles before the wrappers can be
53//! removed:
54//!
55//! 1. **SemVer-bump isolation.** `reqwest::Error` and
56//!    `tungstenite::Error` are `#[non_exhaustive]` from third-party
57//!    crates that bump major versions independently of this crate.
58//!    Exposing them in this crate's public API turns every transitive
59//!    `reqwest` major bump into a SemVer break for every downstream
60//!    extension client (`jmap-mail-client`, `jmap-chat-client`,
61//!    `jmap-calendars-client`, etc., all eight planned extensions).
62//! 2. **Transport replaceability.** This crate may swap the HTTP /
63//!    WebSocket transport entirely — `ureq`, `hyper-util` directly, a
64//!    `curl`-backed transport for an unusual deployment — without
65//!    breaking downstream. The only thing downstream binds to is the
66//!    wrapper's accessor signature; the wrapped type is private and
67//!    can be replaced in-place.
68//! 3. **Curated accessor surface.** Not every `reqwest::Error` method
69//!    is mirrored. Adding new diagnostic surface (e.g.
70//!    `WebSocketError::close_code()`) requires a deliberate
71//!    `pub fn` decision in this file, which surfaces in code review.
72//!    An unwrapped error would silently grow the surface every
73//!    `reqwest` minor release.
74//! 4. **Opaque construction.** The `pub(crate) from_reqwest` /
75//!    `from_ws` / `from_invalid_header` helpers on `ClientError`
76//!    mean downstream cannot construct the transport-error variants
77//!    even if they wanted to. That keeps the variants genuinely
78//!    opaque — no "well, the public field is a `reqwest::Error`, so
79//!    downstream can match on it" loophole.
80//! 5. **Workspace policy alignment.** This crate's `AGENTS.md`
81//!    "Design Constraints" table documents the wrappers as settled
82//!    after bd:JMAP-6lsm.22. The workspace `AGENTS.md` "TLS stack"
83//!    rule additionally forbids native-tls / openssl; allowing
84//!    `reqwest::Error` to leak would re-couple downstream to the
85//!    `reqwest` feature-set decisions this crate has already made.
86//!
87//! The accessor set on each wrapper is deliberately minimal. Resist
88//! requests to "just expose `reqwest::Error`" without re-arguing all
89//! five reasons above.
90
91use std::error::Error as StdError;
92use std::fmt;
93use std::time::{SystemTime, UNIX_EPOCH};
94
95// ---------------------------------------------------------------------------
96// HttpError — opaque wrapper around reqwest::Error
97// ---------------------------------------------------------------------------
98
99/// HTTP transport error reported by the underlying HTTP client.
100///
101/// The inner third-party error type is private; callers diagnose the failure
102/// via the accessor methods, all of which return primitive types so this
103/// crate can swap or bump the underlying HTTP client without breaking the
104/// public API.
105#[non_exhaustive]
106pub struct HttpError(reqwest::Error);
107
108impl HttpError {
109    /// `true` if the request timed out before a response was received.
110    pub fn is_timeout(&self) -> bool {
111        self.0.is_timeout()
112    }
113    /// `true` if the underlying connection could not be established
114    /// (DNS failure, TCP refused, TLS handshake failure, etc.).
115    pub fn is_connect(&self) -> bool {
116        self.0.is_connect()
117    }
118    /// `true` if the error originated in the request builder
119    /// (URL parse failure, invalid header construction at build time, etc.).
120    pub fn is_builder(&self) -> bool {
121        self.0.is_builder()
122    }
123    /// `true` if the error is a redirect-loop or too-many-redirects failure.
124    pub fn is_redirect(&self) -> bool {
125        self.0.is_redirect()
126    }
127    /// `true` if the error originated from a non-success HTTP status.
128    pub fn is_status(&self) -> bool {
129        self.0.is_status()
130    }
131    /// `true` if the error happened while sending the request body.
132    pub fn is_request(&self) -> bool {
133        self.0.is_request()
134    }
135    /// `true` if the error happened while receiving / decoding the response body.
136    pub fn is_body(&self) -> bool {
137        self.0.is_body()
138    }
139    /// `true` if the response body could not be decoded as the requested
140    /// representation (e.g. JSON parse failure inside the transport layer).
141    pub fn is_decode(&self) -> bool {
142        self.0.is_decode()
143    }
144    /// HTTP status code if the error came from a non-success response;
145    /// `None` for transport-level failures (timeout, connection refused, etc.).
146    pub fn status(&self) -> Option<u16> {
147        self.0.status().map(|s| s.as_u16())
148    }
149    /// URL the request was sent to, if known. Returned as an owned `String`
150    /// to avoid leaking the underlying transport's `Url` type into this
151    /// crate's public API.
152    pub fn url(&self) -> Option<String> {
153        self.0.url().map(ToString::to_string)
154    }
155
156    /// Classify the error into a single category (bd:JMAP-6r7c.34).
157    ///
158    /// The 8 [`is_*`](HttpError::is_timeout) boolean accessors are
159    /// useful when a caller wants to test for a specific category, but
160    /// they leave the caller writing a chained-`if-else` dispatch with
161    /// undocumented mutual relationships ("can `is_status` and
162    /// `is_decode` both be true?"). This method returns a single
163    /// `HttpErrorKind` so a caller can `match` on it once.
164    ///
165    /// Precedence (highest first) when multiple predicates could
166    /// arguably apply: `Timeout`, `Connect`, `Redirect`, `Status`,
167    /// `RequestBody`, `ResponseBody`, `Decode`, `Builder`, `Other`.
168    /// Status takes precedence over body/decode because reqwest sets
169    /// `is_status` precisely when the failure is "the HTTP server
170    /// returned a non-success status code"; in that case the body or
171    /// decode flag may also be set, but the most-actionable
172    /// classification for a caller is the status code itself.
173    ///
174    /// This method does not return retriability advice — the same
175    /// `Status(429)` may be retriable or fatal depending on the
176    /// `Retry-After` header value, and the same `Connect` may mean
177    /// "DNS not yet warm" (retry) or "host is down" (give up). Make
178    /// the retriability decision at the call site, using the kind as
179    /// input.
180    pub fn kind(&self) -> HttpErrorKind {
181        if self.0.is_timeout() {
182            HttpErrorKind::Timeout
183        } else if self.0.is_connect() {
184            HttpErrorKind::Connect
185        } else if self.0.is_redirect() {
186            HttpErrorKind::Redirect
187        } else if let Some(s) = self.0.status() {
188            HttpErrorKind::Status(s.as_u16())
189        } else if self.0.is_request() {
190            HttpErrorKind::RequestBody
191        } else if self.0.is_body() {
192            HttpErrorKind::ResponseBody
193        } else if self.0.is_decode() {
194            HttpErrorKind::Decode
195        } else if self.0.is_builder() {
196            HttpErrorKind::Builder
197        } else {
198            HttpErrorKind::Other
199        }
200    }
201}
202
203/// Classification of an [`HttpError`] returned by [`HttpError::kind`].
204///
205/// A coarse partition over the failure modes reqwest reports. Use this
206/// when a caller wants to dispatch on the error category in a single
207/// `match`; the [`HttpError::is_timeout`] / [`HttpError::is_connect`]
208/// boolean accessors remain available for callers that test for one
209/// specific category.
210///
211/// `#[non_exhaustive]` so new variants may be added in minor releases
212/// without breaking callers.
213#[non_exhaustive]
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215pub enum HttpErrorKind {
216    /// Request timed out before a response was received
217    /// ([`HttpError::is_timeout`]).
218    Timeout,
219    /// Underlying connection could not be established — DNS failure,
220    /// TCP refused, TLS handshake failure ([`HttpError::is_connect`]).
221    Connect,
222    /// Redirect loop or too-many-redirects failure
223    /// ([`HttpError::is_redirect`]).
224    Redirect,
225    /// Server returned a non-success HTTP status. The payload is the
226    /// status code as `u16` (e.g. `404`, `429`, `503`). 401 / 403 are
227    /// not surfaced here — they are caught earlier and produce
228    /// [`ClientError::AuthFailed`].
229    Status(u16),
230    /// Error happened while sending the request body
231    /// ([`HttpError::is_request`]).
232    RequestBody,
233    /// Error happened while receiving the response body
234    /// ([`HttpError::is_body`]).
235    ResponseBody,
236    /// Response body could not be decoded as the requested representation
237    /// (e.g. JSON parse failure inside the transport layer)
238    /// ([`HttpError::is_decode`]).
239    Decode,
240    /// Error originated in the request builder — URL parse failure,
241    /// invalid header construction at build time, etc.
242    /// ([`HttpError::is_builder`]). Indicates a caller bug.
243    Builder,
244    /// Categorisation did not match any of the predicates above.
245    /// May appear for transport-level errors that reqwest reports
246    /// without setting any of the typed predicates.
247    Other,
248}
249
250impl fmt::Display for HttpError {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        fmt::Display::fmt(&self.0, f)
253    }
254}
255
256impl fmt::Debug for HttpError {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.debug_tuple("HttpError").field(&self.0).finish()
259    }
260}
261
262impl StdError for HttpError {
263    fn source(&self) -> Option<&(dyn StdError + 'static)> {
264        Some(&self.0)
265    }
266}
267
268// ---------------------------------------------------------------------------
269// WebSocketError — opaque wrapper around tokio_tungstenite::tungstenite::Error
270// ---------------------------------------------------------------------------
271
272/// WebSocket transport error reported by the underlying WebSocket client.
273///
274/// As with [`HttpError`], the inner third-party type is private and
275/// diagnostics are exposed via accessor methods returning primitive types.
276#[non_exhaustive]
277pub struct WebSocketError(tokio_tungstenite::tungstenite::Error);
278
279impl WebSocketError {
280    /// `true` if the peer cleanly closed the connection.
281    pub fn is_connection_closed(&self) -> bool {
282        matches!(
283            &self.0,
284            tokio_tungstenite::tungstenite::Error::ConnectionClosed
285        )
286    }
287    /// `true` if the connection was already closed when the operation was
288    /// attempted (caller bug or race).
289    pub fn is_already_closed(&self) -> bool {
290        matches!(
291            &self.0,
292            tokio_tungstenite::tungstenite::Error::AlreadyClosed
293        )
294    }
295    /// `true` if the error wraps an underlying `std::io::Error`.
296    pub fn is_io(&self) -> bool {
297        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Io(_))
298    }
299    /// `true` if the error is a WebSocket protocol violation
300    /// (malformed frame, invalid opcode, etc.).
301    pub fn is_protocol(&self) -> bool {
302        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Protocol(_))
303    }
304    /// `true` if a frame or message exceeded a configured size limit.
305    pub fn is_capacity(&self) -> bool {
306        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Capacity(_))
307    }
308    /// `true` if the WebSocket URL was invalid.
309    pub fn is_url(&self) -> bool {
310        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Url(_))
311    }
312
313    /// Classify the error into a single category (bd:JMAP-6r7c.34).
314    ///
315    /// Single-`match` alternative to the 6 [`is_*`](WebSocketError::is_io)
316    /// boolean accessors. Returns a [`WebSocketErrorKind`] so a caller
317    /// can dispatch on the failure mode without chained-`if-else`.
318    ///
319    /// Precedence (highest first): `ConnectionClosed`, `AlreadyClosed`,
320    /// `Url`, `Protocol`, `Capacity`, `Io`, `Other`. The first three
321    /// are exact tungstenite variants and are mutually exclusive;
322    /// the remainder follow the `is_*` accessor order from this file.
323    /// This method does not return retriability advice — make that
324    /// decision at the call site using the kind as input.
325    pub fn kind(&self) -> WebSocketErrorKind {
326        use tokio_tungstenite::tungstenite::Error as TError;
327        match &self.0 {
328            TError::ConnectionClosed => WebSocketErrorKind::ConnectionClosed,
329            TError::AlreadyClosed => WebSocketErrorKind::AlreadyClosed,
330            TError::Url(_) => WebSocketErrorKind::Url,
331            TError::Protocol(_) => WebSocketErrorKind::Protocol,
332            TError::Capacity(_) => WebSocketErrorKind::Capacity,
333            TError::Io(_) => WebSocketErrorKind::Io,
334            _ => WebSocketErrorKind::Other,
335        }
336    }
337}
338
339/// Classification of a [`WebSocketError`] returned by [`WebSocketError::kind`].
340///
341/// `#[non_exhaustive]` so new variants may be added in minor releases
342/// without breaking callers.
343#[non_exhaustive]
344#[derive(Debug, Clone, PartialEq, Eq, Hash)]
345pub enum WebSocketErrorKind {
346    /// Peer cleanly closed the connection
347    /// ([`WebSocketError::is_connection_closed`]).
348    ConnectionClosed,
349    /// Connection was already closed when the operation was attempted
350    /// — caller bug or race ([`WebSocketError::is_already_closed`]).
351    AlreadyClosed,
352    /// WebSocket URL was invalid ([`WebSocketError::is_url`]).
353    Url,
354    /// WebSocket protocol violation — malformed frame, invalid opcode,
355    /// etc. ([`WebSocketError::is_protocol`]).
356    Protocol,
357    /// Frame or message exceeded a configured size limit
358    /// ([`WebSocketError::is_capacity`]).
359    Capacity,
360    /// Error wraps an underlying `std::io::Error`
361    /// ([`WebSocketError::is_io`]).
362    Io,
363    /// Categorisation did not match any of the variants above. May
364    /// appear for tungstenite error variants not yet covered by this
365    /// crate's classification (e.g. `Tls`, `Http`, future additions).
366    Other,
367}
368
369impl fmt::Display for WebSocketError {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        fmt::Display::fmt(&self.0, f)
372    }
373}
374
375impl fmt::Debug for WebSocketError {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        f.debug_tuple("WebSocketError").field(&self.0).finish()
378    }
379}
380
381impl StdError for WebSocketError {
382    fn source(&self) -> Option<&(dyn StdError + 'static)> {
383        Some(&self.0)
384    }
385}
386
387// ---------------------------------------------------------------------------
388// InvalidHeaderValueError — string-only wrapper, no third-party leak
389// ---------------------------------------------------------------------------
390
391/// A header value (typically an authentication token) contained bytes that
392/// are not valid for an HTTP header.
393///
394/// The inner type is just a string message; there is no actionable
395/// diagnostic state beyond that, so this wrapper does not expose any
396/// accessor beyond [`Display`](fmt::Display).
397#[non_exhaustive]
398pub struct InvalidHeaderValueError {
399    message: String,
400}
401
402impl InvalidHeaderValueError {
403    /// The human-readable description of the failure.
404    pub fn message(&self) -> &str {
405        &self.message
406    }
407}
408
409impl fmt::Display for InvalidHeaderValueError {
410    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411        f.write_str(&self.message)
412    }
413}
414
415impl fmt::Debug for InvalidHeaderValueError {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        f.debug_struct("InvalidHeaderValueError")
418            .field("message", &self.message)
419            .finish()
420    }
421}
422
423impl StdError for InvalidHeaderValueError {}
424
425// ---------------------------------------------------------------------------
426// ParseError / SerializeError — opaque wrappers around serde_json::Error
427// ---------------------------------------------------------------------------
428
429/// JSON deserialize / parse error reported by the underlying JSON parser.
430///
431/// The inner third-party error type is private; callers diagnose the
432/// failure via the accessor methods on this wrapper, all of which return
433/// primitive types or the workspace-local [`ParseCategory`] enum. The
434/// wrapper exists so that the [`ClientError::Parse`] variant payload is
435/// insulated from a future JSON-parser swap (bd:JMAP-6r7c.26).
436///
437/// Construct via [`ClientError::from_parse`] (the common path) or
438/// [`ParseError::from_serde_json`] (the low-level path that returns a
439/// wrapper without classifying it into a [`ClientError`] variant).
440#[non_exhaustive]
441pub struct ParseError(serde_json::Error);
442
443impl ParseError {
444    /// Wrap a [`serde_json::Error`] into a [`ParseError`].
445    ///
446    /// Most callers want [`ClientError::from_parse`] which wraps and
447    /// classifies in one step; this lower-level constructor is for
448    /// callers that need to hold a [`ParseError`] before deciding
449    /// whether to surface it as [`ClientError::Parse`] or in some
450    /// other variant.
451    pub fn from_serde_json(e: serde_json::Error) -> Self {
452        Self(e)
453    }
454
455    /// One-based line number where parsing failed, or `0` if the parser
456    /// does not report a line number for this error category.
457    pub fn line(&self) -> usize {
458        self.0.line()
459    }
460
461    /// One-based column number where parsing failed, or `0` if the parser
462    /// does not report a column number for this error category.
463    pub fn column(&self) -> usize {
464        self.0.column()
465    }
466
467    /// Coarse-grained category of the failure.
468    ///
469    /// See [`ParseCategory`] for the variant set and what each one means.
470    pub fn classify(&self) -> ParseCategory {
471        ParseCategory::from(self.0.classify())
472    }
473}
474
475impl fmt::Display for ParseError {
476    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477        fmt::Display::fmt(&self.0, f)
478    }
479}
480
481impl fmt::Debug for ParseError {
482    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
483        f.debug_tuple("ParseError").field(&self.0).finish()
484    }
485}
486
487impl StdError for ParseError {
488    fn source(&self) -> Option<&(dyn StdError + 'static)> {
489        Some(&self.0)
490    }
491}
492
493/// JSON serialize error reported by the underlying JSON serializer.
494///
495/// Shape mirrors [`ParseError`]; see that type for the SemVer-isolation
496/// rationale. Indicates a caller bug — the data structure passed to a
497/// serializer contains non-serializable values (`f64::NAN`, a map keyed
498/// by a non-stringifiable type, etc.).
499#[non_exhaustive]
500pub struct SerializeError(serde_json::Error);
501
502impl SerializeError {
503    /// Wrap a [`serde_json::Error`] into a [`SerializeError`].
504    ///
505    /// Most callers want [`ClientError::from_serialize`] which wraps and
506    /// classifies in one step.
507    pub fn from_serde_json(e: serde_json::Error) -> Self {
508        Self(e)
509    }
510
511    /// One-based line number where serialization failed, or `0` if the
512    /// serializer does not report a line for this category.
513    pub fn line(&self) -> usize {
514        self.0.line()
515    }
516
517    /// One-based column number where serialization failed, or `0` if the
518    /// serializer does not report a column for this category.
519    pub fn column(&self) -> usize {
520        self.0.column()
521    }
522
523    /// Coarse-grained category of the failure.
524    pub fn classify(&self) -> ParseCategory {
525        ParseCategory::from(self.0.classify())
526    }
527}
528
529impl fmt::Display for SerializeError {
530    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
531        fmt::Display::fmt(&self.0, f)
532    }
533}
534
535impl fmt::Debug for SerializeError {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        f.debug_tuple("SerializeError").field(&self.0).finish()
538    }
539}
540
541impl StdError for SerializeError {
542    fn source(&self) -> Option<&(dyn StdError + 'static)> {
543        Some(&self.0)
544    }
545}
546
547/// Classification of a [`ParseError`] / [`SerializeError`] failure.
548///
549/// Maps onto the four `serde_json::error::Category` variants without
550/// re-exporting the third-party enum; the workspace-local enum is what
551/// callers pattern-match on, insulating them from a future JSON-parser
552/// swap.
553#[non_exhaustive]
554#[derive(Debug, Clone, PartialEq, Eq, Hash)]
555pub enum ParseCategory {
556    /// The error was caused by a failure to read or write bytes on an
557    /// I/O stream.
558    Io,
559    /// The error was caused by input that was not syntactically valid
560    /// JSON.
561    Syntax,
562    /// The error was caused by input data that was semantically incorrect
563    /// (e.g. a JSON value of the wrong type for the target Rust type).
564    Data,
565    /// The error was caused by prematurely-terminated input.
566    Eof,
567}
568
569impl From<serde_json::error::Category> for ParseCategory {
570    fn from(cat: serde_json::error::Category) -> Self {
571        match cat {
572            serde_json::error::Category::Io => Self::Io,
573            serde_json::error::Category::Syntax => Self::Syntax,
574            serde_json::error::Category::Data => Self::Data,
575            serde_json::error::Category::Eof => Self::Eof,
576        }
577    }
578}
579
580// ---------------------------------------------------------------------------
581// ClientError
582// ---------------------------------------------------------------------------
583
584/// Errors produced by the base JMAP client.
585///
586/// Variants cover transport failures (`Http`, `WebSocket`), authentication
587/// (`AuthFailed`), JMAP protocol errors (`MethodError`, `UnexpectedResponse`),
588/// caller bugs (`InvalidArgument`, `InvalidHeaderValue`, `Serialize`), and
589/// resource-exhaustion guards (`ResponseTooLarge`, `SseFrameTooLarge`).
590///
591/// Marked `#[non_exhaustive]` so additional variants may be introduced in
592/// minor releases. See per-variant documentation for retriability guidance.
593#[non_exhaustive]
594#[derive(Debug, thiserror::Error)]
595pub enum ClientError {
596    /// Network or TLS error from the HTTP layer. May be retriable (transient
597    /// network failure) or permanent (TLS configuration error). Indicates a
598    /// network or transport problem, not a JMAP protocol error.
599    ///
600    /// The payload is an opaque [`HttpError`] that does not expose any
601    /// third-party error type — this crate's HTTP transport can be swapped
602    /// or its major version bumped without affecting downstream callers.
603    /// Use [`HttpError::is_timeout`], [`HttpError::status`], etc. to diagnose.
604    #[error("HTTP error: {0}")]
605    Http(HttpError),
606
607    /// A header value could not be encoded. Indicates a caller bug — the
608    /// credential string contains characters that are not valid HTTP header
609    /// value characters. Not retriable.
610    #[error("invalid header value: {0}")]
611    InvalidHeaderValue(InvalidHeaderValueError),
612
613    /// The server returned HTTP 401 (authentication failure) or 403
614    /// (authorization failure — credentials present but insufficient). Not
615    /// retriable without correcting credentials.
616    #[error("authentication or authorization failure: HTTP {0}")]
617    AuthFailed(u16),
618
619    /// A server response could not be parsed or did not match the expected
620    /// shape. Indicates the server sent a malformed response. Not retriable
621    /// without a server fix.
622    ///
623    /// The payload is an opaque [`ParseError`] that does not expose the
624    /// underlying JSON parser's error type — the variant is insulated from
625    /// a future parser swap. Construct explicitly:
626    /// `.map_err(ClientError::from_parse)` (or
627    /// `.map_err(jmap_base_client::ClientError::from_parse)` from outside
628    /// the crate). Pattern-match via `ClientError::Parse(e) => e.line()`,
629    /// `e.column()`, `e.classify()` — see [`ParseError`].
630    #[error("parse error: {0}")]
631    Parse(ParseError),
632
633    /// Blob SHA-256 mismatch on upload or download. Indicates in-transit
634    /// corruption or a misbehaving server. Not retriable without re-fetching
635    /// metadata.
636    ///
637    /// # Field semantics across both call sites (bd:JMAP-6r7c.10)
638    ///
639    /// The same variant is emitted from both upload and download paths and
640    /// the role of each field is constant across paths:
641    ///
642    /// - `expected` is the **pre-stated digest** the client was comparing
643    ///   against — i.e. the value that should hold if the bytes are intact.
644    /// - `actual` is the **freshly-observed digest** the client just
645    ///   computed or just learned.
646    ///
647    /// The *source* of each value depends on which call produced the error:
648    ///
649    /// | Call site | `expected` | `actual` |
650    /// |---|---|---|
651    /// | [`JmapClient::upload_blob`](crate::JmapClient::upload_blob) | client's own SHA-256 of the bytes about to be uploaded | server's reported SHA-256 in the upload response |
652    /// | [`JmapClient::download_blob`](crate::JmapClient::download_blob) | `DownloadBlobParams::expected_sha256` supplied by the caller (typed [`jmap_cid_types::Sha256`], guaranteed canonical lowercase) | client's SHA-256 of the actually-received bytes |
653    /// | [`JmapClient::upload_blob_stream`](crate::JmapClient::upload_blob_stream) | client's SHA-256 of the bytes streamed to the server | server's reported SHA-256 in the upload response |
654    /// | [`JmapClient::download_blob_stream`](crate::JmapClient::download_blob_stream) (final stream item) | `DownloadBlobParams::expected_sha256` supplied by the caller | client's SHA-256 of all streamed bytes |
655    ///
656    /// Both digests are canonical 64-character lowercase hex (per
657    /// draft-atwood-jmap-cid-00 §2 ABNF).
658    #[error("blob integrity check failed: expected {expected}, got {actual}")]
659    BlobIntegrityMismatch {
660        /// Pre-stated SHA-256 hex digest the client was comparing against.
661        /// On upload, this is the client's own pre-upload computation; on
662        /// download, this is the caller-supplied
663        /// [`DownloadBlobParams::expected_sha256`](crate::DownloadBlobParams::expected_sha256)
664        /// (typed [`jmap_cid_types::Sha256`], guaranteed canonical lowercase).
665        expected: String,
666        /// Freshly-observed SHA-256 hex digest. On upload, this is the
667        /// server-reported digest from the upload response. On download,
668        /// this is the client's own digest over the received bytes.
669        actual: String,
670    },
671
672    /// A caller-supplied argument violates a precondition (e.g. empty token,
673    /// colon in BasicAuth username, missing required filter field).
674    #[error("invalid argument: {0}")]
675    InvalidArgument(String),
676
677    /// The JMAP Session object from the server was missing a required field.
678    /// Indicates a server-side bug or incompatible server. Not retriable.
679    #[error("invalid session: {0}")]
680    InvalidSession(String),
681
682    /// The JMAP API response did not contain the expected method call ID.
683    /// Indicates a server-side bug or unexpected response shape.
684    #[error("method not found in response: {0}")]
685    MethodNotFound(String),
686
687    /// The JMAP server returned a method-level error object (RFC 8620 §3.6).
688    /// Retriability depends on `error_type` (e.g. `serverFail` may be
689    /// retried; `invalidArguments` is not retriable).
690    ///
691    /// `description` is `None` when the server omits the optional description field.
692    #[error("JMAP method error: {error_type}")]
693    MethodError {
694        /// The `type` field of the JMAP method-level error object (RFC 8620 §3.6.2),
695        /// e.g. `"invalidArguments"`, `"serverFail"`, `"accountNotFound"`.
696        error_type: String,
697        /// Optional human-readable error description (RFC 8620 §3.6.2);
698        /// `None` when the server omits this field.
699        description: Option<String>,
700    },
701
702    /// A JMAP request could not be serialized to JSON when sending over
703    /// WebSocket. Indicates a caller bug — the data structure contains
704    /// non-serializable values. Not retriable.
705    ///
706    /// This error is only returned by [`WsSession::send_request`](crate::WsSession::send_request); the HTTP
707    /// `call()` path delegates serialization to reqwest, which surfaces
708    /// serialization failures as [`ClientError::Http`].
709    ///
710    /// The payload is an opaque [`SerializeError`] that does not expose the
711    /// underlying JSON serializer's error type. Construct explicitly:
712    /// `.map_err(ClientError::from_serialize)`. Pattern-match via
713    /// `ClientError::Serialize(e) => e.line()`, `e.column()`, `e.classify()`
714    /// — see [`SerializeError`].
715    #[error("serialization error: {0}")]
716    Serialize(SerializeError),
717
718    /// An SSE frame exceeded the configured buffer limit
719    /// ([`ClientConfig::max_sse_frame`](crate::ClientConfig::max_sse_frame)). The stream is terminated after this
720    /// error. Indicates a misbehaving or hostile server.
721    #[error("SSE frame too large (limit: {limit} bytes)")]
722    SseFrameTooLarge {
723        /// The configured per-frame buffer cap (in bytes) that was exceeded.
724        limit: usize,
725    },
726
727    /// A server response body exceeded the enforced size limit. Protects
728    /// against unbounded memory allocation from malicious or buggy servers.
729    /// `actual` is in bytes (from Content-Length or actual read size).
730    #[error("response too large: {actual} bytes exceeds limit of {limit} bytes")]
731    ResponseTooLarge {
732        /// Observed response size in bytes (from `Content-Length` or the
733        /// running total of bytes read so far when streaming).
734        actual: u64,
735        /// Configured maximum response size in bytes.
736        limit: u64,
737    },
738
739    /// A WebSocket transport error (connection, framing, or TLS). May be
740    /// retriable (transient network failure) or permanent (TLS config error).
741    ///
742    /// The payload is an opaque [`WebSocketError`] that does not expose any
743    /// third-party error type — see [`HttpError`] for the same SemVer
744    /// rationale. Use [`WebSocketError::is_io`],
745    /// [`WebSocketError::is_protocol`], etc. to diagnose.
746    #[error("WebSocket error: {0}")]
747    WebSocket(WebSocketError),
748
749    /// The server returned a response that violates the JMAP protocol (outside
750    /// the Session fetch path). Examples: wrong `Content-Type` on an SSE
751    /// connection, unexpected response shape on a non-session endpoint.
752    ///
753    /// Distinct from [`ClientError::InvalidSession`], which indicates a
754    /// problem with the Session document itself. Not retriable without a
755    /// server fix.
756    #[error("unexpected server response: {0}")]
757    UnexpectedResponse(String),
758
759    /// Server rate-limited the request. `retry_after` indicates when to retry.
760    ///
761    /// Produced for an HTTP 429 (Too Many Requests) response from
762    /// [`JmapClient::fetch_session`](crate::JmapClient::fetch_session),
763    /// [`JmapClient::call`](crate::JmapClient::call),
764    /// [`JmapClient::upload_blob`](crate::JmapClient::upload_blob) and
765    /// [`JmapClient::download_blob`](crate::JmapClient::download_blob)
766    /// (and the `_session` wrappers that delegate to them). Extension crates
767    /// also produce it, e.g. for a `rateLimited` SetError. A 429 on the
768    /// event-source connection still surfaces as [`ClientError::Http`] so
769    /// [`JmapClient::subscribe_events_reconnecting`](crate::JmapClient::subscribe_events_reconnecting)
770    /// can keep retrying it.
771    ///
772    /// `retry_after` comes from the `Retry-After` header (RFC 9110 §10.2.3):
773    /// a delay-seconds value is added to the time the response was received,
774    /// and an HTTP-date in any of the three RFC 9110 §5.6.7 formats is used
775    /// as given. When the header is absent or unparseable, `retry_after` is
776    /// the time the response was received — the server gave no delay, so the
777    /// caller should apply its own backoff. Instants past
778    /// `9999-12-31T23:59:59Z` are clamped to it.
779    #[error("rate limited; retry after {retry_after}")]
780    RateLimited {
781        /// Absolute UTC instant the client should wait until before retrying,
782        /// parsed from the `Retry-After` HTTP header (RFC 9110 §10.2.3).
783        retry_after: jmap_types::UTCDate,
784    },
785}
786
787impl ClientError {
788    /// Convert a [`reqwest::Error`] into a [`ClientError::Http`] variant.
789    ///
790    /// `pub(crate)` so downstream callers cannot construct transport-error
791    /// variants — that responsibility belongs to this crate's transport
792    /// layer alone. This is the only conversion path from the third-party
793    /// type into `ClientError`, and is the reason this crate's public API
794    /// no longer mentions `reqwest::Error`.
795    pub(crate) fn from_reqwest(e: reqwest::Error) -> Self {
796        Self::Http(HttpError(e))
797    }
798
799    /// Convert a [`tokio_tungstenite::tungstenite::Error`] into a
800    /// [`ClientError::WebSocket`] variant. See
801    /// [`from_reqwest`](Self::from_reqwest) for the SemVer rationale.
802    pub(crate) fn from_ws(e: tokio_tungstenite::tungstenite::Error) -> Self {
803        Self::WebSocket(WebSocketError(e))
804    }
805
806    /// Convert a [`reqwest::header::InvalidHeaderValue`] into a
807    /// [`ClientError::InvalidHeaderValue`] variant. The inner third-party
808    /// type carries no actionable diagnostic state, so we keep only the
809    /// `Display` representation as a `String`.
810    pub(crate) fn from_invalid_header(e: reqwest::header::InvalidHeaderValue) -> Self {
811        Self::InvalidHeaderValue(InvalidHeaderValueError {
812            message: e.to_string(),
813        })
814    }
815
816    /// Convert a [`serde_json::Error`] from a deserialize / parse step
817    /// into a [`ClientError::Parse`] variant carrying an opaque
818    /// [`ParseError`] payload (bd:JMAP-6r7c.26).
819    ///
820    /// Public because extension client crates (`jmap-mail-client`,
821    /// `jmap-chat-client`, etc.) need to surface their own JSON parse
822    /// failures as [`ClientError::Parse`]. Use in `.map_err`:
823    ///
824    /// ```rust,ignore
825    /// let val: T = serde_json::from_slice(&bytes)
826    ///     .map_err(jmap_base_client::ClientError::from_parse)?;
827    /// ```
828    ///
829    /// A future JSON-parser swap would deprecate this constructor in
830    /// favor of an analogous one for the new parser; the variant
831    /// payload type ([`ParseError`]) stays stable.
832    pub fn from_parse(e: serde_json::Error) -> Self {
833        Self::Parse(ParseError(e))
834    }
835
836    /// Convert a [`serde_json::Error`] from a serialize step into a
837    /// [`ClientError::Serialize`] variant carrying an opaque
838    /// [`SerializeError`] payload (bd:JMAP-6r7c.26).
839    ///
840    /// Public for the same reason as [`from_parse`](Self::from_parse).
841    pub fn from_serialize(e: serde_json::Error) -> Self {
842        Self::Serialize(SerializeError(e))
843    }
844
845    /// Build a [`ClientError::RateLimited`] for an HTTP 429 response
846    /// received at `now`, from its `Retry-After` value (RFC 9110 §10.2.3).
847    /// See the variant docs for the absent / unparseable fallback.
848    pub(crate) fn rate_limited(retry_after: Option<&str>, now: SystemTime) -> Self {
849        let now_secs = now.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
850        let at = retry_after.map(str::trim).and_then(|v| {
851            // delay-seconds = 1*DIGIT; `u64::from_str` alone would also accept "+120".
852            if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
853                // A delay too long for u64 is still "not before year 9999".
854                Some(
855                    v.parse::<u64>()
856                        .map_or(u64::MAX, |d| now_secs.saturating_add(d)),
857                )
858            } else {
859                let t = httpdate::parse_http_date(v).ok()?;
860                t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
861            }
862        });
863        Self::RateLimited {
864            retry_after: utcdate_from_epoch(at.unwrap_or(now_secs)),
865        }
866    }
867}
868
869/// Format seconds since the Unix epoch as an RFC 8620 §1.4 `UTCDate`,
870/// clamped to `9999-12-31T23:59:59Z` (the largest four-digit year).
871///
872/// Uses Howard Hinnant's `civil_from_days` ("chrono-Compatible Low-Level
873/// Date Algorithms"), the inverse of the `days_from_civil` that
874/// [`jmap_types::UTCDate::to_epoch_seconds`] uses.
875fn utcdate_from_epoch(secs: u64) -> jmap_types::UTCDate {
876    let secs = secs.min(253_402_300_799);
877    let (days, rem) = (secs / 86_400, secs % 86_400);
878    let z = days + 719_468;
879    let era = z / 146_097;
880    let doe = z - era * 146_097; // [0, 146096]
881    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
882    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
883    let mp = (5 * doy + 2) / 153; // [0, 11], March-based month
884    let day = doy - (153 * mp + 2) / 5 + 1;
885    let month = if mp < 10 { mp + 3 } else { mp - 9 };
886    let year = yoe + era * 400 + u64::from(month <= 2);
887    format!(
888        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
889        rem / 3600,
890        rem / 60 % 60,
891        rem % 60
892    )
893    .into()
894}
895
896// ---------------------------------------------------------------------------
897// Tests
898// ---------------------------------------------------------------------------
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    /// Verify ClientError variants by exhaustive match. Variant names are
905    /// part of the public API; this catches accidental rename / removal.
906    #[test]
907    fn client_error_exhaustive_match() {
908        let e = ClientError::InvalidArgument("test".into());
909        match e {
910            ClientError::Http(_) => {}
911            ClientError::InvalidHeaderValue(_) => {}
912            ClientError::AuthFailed(_) => {}
913            ClientError::Parse(_) => {}
914            ClientError::BlobIntegrityMismatch { .. } => {}
915            ClientError::InvalidArgument(_) => {}
916            ClientError::InvalidSession(_) => {}
917            ClientError::MethodNotFound(_) => {}
918            ClientError::MethodError { .. } => {}
919            ClientError::Serialize(_) => {}
920            ClientError::SseFrameTooLarge { .. } => {}
921            ClientError::ResponseTooLarge { .. } => {}
922            ClientError::WebSocket(_) => {}
923            ClientError::UnexpectedResponse(_) => {}
924            ClientError::RateLimited { .. } => {}
925        }
926    }
927
928    /// InvalidHeaderValueError preserves the underlying message so the
929    /// Display output matches the third-party error's Display verbatim —
930    /// callers that previously logged the `ClientError::InvalidHeaderValue`
931    /// variant see the same diagnostic text after the wrapper rename.
932    ///
933    /// Independent oracle: reqwest::header::InvalidHeaderValue is produced
934    /// by HeaderValue::from_str on bytes that are not valid header values
935    /// (e.g. embedded newline). The wrapper's Display is just the inner
936    /// type's Display.
937    #[test]
938    fn invalid_header_value_preserves_message() {
939        let inner_err = reqwest::header::HeaderValue::from_str("bad\nvalue")
940            .expect_err("newline must be rejected as a header value");
941        let inner_display = inner_err.to_string();
942
943        let ce = ClientError::from_invalid_header(inner_err);
944        let ClientError::InvalidHeaderValue(ihve) = &ce else {
945            panic!("must be InvalidHeaderValue variant, got {ce:?}");
946        };
947        assert_eq!(
948            ihve.message(),
949            inner_display,
950            "wrapper message must equal inner Display"
951        );
952        // Outer ClientError Display includes the prefix.
953        assert!(
954            ce.to_string().starts_with("invalid header value: "),
955            "ClientError Display must use the variant's #[error] prefix: {ce}"
956        );
957    }
958
959    /// An HttpError constructed from a reqwest builder failure exposes the
960    /// expected diagnostic accessor values: is_builder=true, status=None,
961    /// and a non-empty Display. Independent oracle: reqwest's documented
962    /// behaviour for invalid URLs (builder error, no status code).
963    #[test]
964    fn http_error_from_invalid_url_is_builder_error() {
965        // reqwest::Client::new().get("not a url") produces a builder error
966        // when the URL fails to parse. Building the request and calling
967        // .send() requires async context; .build() is synchronous and
968        // suffices to provoke a parse failure.
969        let client = reqwest::Client::new();
970        let build_err = client
971            .request(reqwest::Method::GET, "://not-a-url")
972            .build()
973            .expect_err("malformed URL must produce a build error");
974
975        let ce = ClientError::from_reqwest(build_err);
976        let ClientError::Http(http_err) = &ce else {
977            panic!("must be Http variant, got {ce:?}");
978        };
979        assert!(
980            http_err.is_builder(),
981            "malformed URL must be classified as a builder error"
982        );
983        assert!(
984            http_err.status().is_none(),
985            "builder errors carry no HTTP status"
986        );
987        assert!(
988            !http_err.is_timeout(),
989            "builder error must not classify as timeout"
990        );
991        assert!(
992            !http_err.is_connect(),
993            "builder error must not classify as connect"
994        );
995        assert!(
996            !http_err.to_string().is_empty(),
997            "Display must produce a non-empty diagnostic"
998        );
999    }
1000
1001    /// A WebSocketError wrapping ConnectionClosed correctly classifies via
1002    /// its accessor methods. Independent oracle: tungstenite's documented
1003    /// Error variants are matched directly via the matches! macro.
1004    #[test]
1005    fn websocket_error_classifies_connection_closed() {
1006        let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
1007        let ce = ClientError::from_ws(inner);
1008        let ClientError::WebSocket(ws_err) = &ce else {
1009            panic!("must be WebSocket variant, got {ce:?}");
1010        };
1011        assert!(ws_err.is_connection_closed());
1012        assert!(!ws_err.is_already_closed());
1013        assert!(!ws_err.is_io());
1014        assert!(!ws_err.is_protocol());
1015        assert!(!ws_err.is_capacity());
1016    }
1017
1018    /// A WebSocketError wrapping an Io variant correctly classifies via
1019    /// is_io. Independent oracle: tungstenite::Error::Io is the documented
1020    /// wrapper for std::io::Error sources.
1021    #[test]
1022    fn websocket_error_classifies_io() {
1023        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test");
1024        let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
1025        let ce = ClientError::from_ws(inner);
1026        let ClientError::WebSocket(ws_err) = &ce else {
1027            panic!("must be WebSocket variant, got {ce:?}");
1028        };
1029        assert!(ws_err.is_io());
1030        assert!(!ws_err.is_connection_closed());
1031        assert!(!ws_err.is_already_closed());
1032    }
1033
1034    /// HttpError, WebSocketError, and InvalidHeaderValueError all implement
1035    /// std::error::Error. This is a regression guard against any future
1036    /// refactor that drops one of these impls (which would silently break
1037    /// downstream code that iterates the source chain via Error::source).
1038    #[test]
1039    fn wrapper_types_implement_std_error() {
1040        fn assert_error<E: StdError>() {}
1041        assert_error::<HttpError>();
1042        assert_error::<WebSocketError>();
1043        assert_error::<InvalidHeaderValueError>();
1044        assert_error::<ParseError>();
1045        assert_error::<SerializeError>();
1046    }
1047
1048    // bd:JMAP-6r7c.26 — wrapping serde_json::Error behind ParseError and
1049    // SerializeError. Independent oracles below: hand-crafted JSON inputs
1050    // whose serde_json::Error categorisation is well-documented.
1051
1052    /// `ClientError::from_parse` wraps a syntax-error JSON failure into a
1053    /// `ClientError::Parse` carrying a `ParseError` whose `classify()` reports
1054    /// `ParseCategory::Syntax`. Oracle: `{` alone is documented as a syntax
1055    /// failure by serde_json (premature `{` with no member).
1056    #[test]
1057    fn parse_error_classifies_syntax_failure() {
1058        let inner =
1059            serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
1060        let ce = ClientError::from_parse(inner);
1061        let ClientError::Parse(pe) = &ce else {
1062            panic!("must be Parse variant, got {ce:?}");
1063        };
1064        // Serde-json reports EOF for an unclosed object on a stream that
1065        // contains nothing after `{` — the input was prematurely
1066        // terminated. Either Syntax or Eof is acceptable here; both are
1067        // structural-parse failures. Lock both as valid outcomes so a
1068        // future serde_json upgrade that flips classification does not
1069        // wrongly fail this test.
1070        assert!(
1071            matches!(pe.classify(), ParseCategory::Syntax | ParseCategory::Eof),
1072            "expected Syntax or Eof, got {:?}",
1073            pe.classify()
1074        );
1075        // line/column are 1-based and non-zero for any parse failure
1076        // with a position; the unclosed `{` has a position.
1077        assert!(pe.line() > 0, "line must be 1-based and non-zero");
1078    }
1079
1080    /// `ClientError::from_parse` on a type-mismatch error reports
1081    /// `ParseCategory::Data`. Oracle: feeding `"not a number"` to
1082    /// `serde_json::from_str::<u32>` is documented as a Data error
1083    /// (semantic mismatch, not syntax).
1084    #[test]
1085    fn parse_error_classifies_data_failure() {
1086        let inner = serde_json::from_str::<u32>("\"not a number\"")
1087            .expect_err("string must fail to deserialise as u32");
1088        let ce = ClientError::from_parse(inner);
1089        let ClientError::Parse(pe) = &ce else {
1090            panic!("must be Parse variant, got {ce:?}");
1091        };
1092        assert_eq!(pe.classify(), ParseCategory::Data);
1093    }
1094
1095    /// `ClientError::from_serialize` wraps a `serde_json::Error` from a
1096    /// serialise step into a `ClientError::Serialize` carrying an opaque
1097    /// `SerializeError`. Oracle: a `BTreeMap` whose keys are a tuple
1098    /// type cannot serialise as JSON because JSON object keys MUST be
1099    /// strings — serde_json documents this as `Error::key_must_be_a_string`.
1100    #[test]
1101    fn serialize_error_wraps_non_string_map_key() {
1102        let mut map: std::collections::BTreeMap<(i32, i32), &str> =
1103            std::collections::BTreeMap::new();
1104        map.insert((1, 2), "value");
1105        let inner = serde_json::to_string(&map)
1106            .expect_err("tuple-keyed map must not serialise as a JSON object");
1107        let ce = ClientError::from_serialize(inner);
1108        let ClientError::Serialize(se) = &ce else {
1109            panic!("must be Serialize variant, got {ce:?}");
1110        };
1111        // Display surface is non-empty (the underlying serde_json::Error
1112        // produces a diagnostic).
1113        assert!(
1114            !se.to_string().is_empty(),
1115            "SerializeError Display must be non-empty"
1116        );
1117    }
1118
1119    /// `ParseError`'s `Debug` output formats as `ParseError(...)` with the
1120    /// inner serde_json::Error nested, NOT as a bare serde_json::Error.
1121    /// This locks in the wrapper-rename for any code that pattern-matches
1122    /// on Debug output (e.g. a snapshot test) without exposing the inner
1123    /// type as the top-level shape.
1124    #[test]
1125    fn parse_error_debug_format_uses_wrapper_name() {
1126        let inner =
1127            serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
1128        let ce = ClientError::from_parse(inner);
1129        let ClientError::Parse(pe) = &ce else {
1130            panic!("must be Parse variant");
1131        };
1132        let debug = format!("{pe:?}");
1133        assert!(
1134            debug.starts_with("ParseError("),
1135            "Debug must use the wrapper tuple-struct name: {debug}"
1136        );
1137    }
1138
1139    /// `SerializeError` Debug format mirror.
1140    #[test]
1141    fn serialize_error_debug_format_uses_wrapper_name() {
1142        let mut map: std::collections::BTreeMap<(i32, i32), &str> =
1143            std::collections::BTreeMap::new();
1144        map.insert((1, 2), "value");
1145        let inner = serde_json::to_string(&map)
1146            .expect_err("tuple-keyed map must not serialise as a JSON object");
1147        let ce = ClientError::from_serialize(inner);
1148        let ClientError::Serialize(se) = &ce else {
1149            panic!("must be Serialize variant");
1150        };
1151        let debug = format!("{se:?}");
1152        assert!(
1153            debug.starts_with("SerializeError("),
1154            "Debug must use the wrapper tuple-struct name: {debug}"
1155        );
1156    }
1157
1158    /// bd:JMAP-6r7c.34 — verify every HttpErrorKind variant by exhaustive
1159    /// match. A future variant addition (forgetting to update this match)
1160    /// fails the test, forcing a deliberate choice rather than silent
1161    /// API drift.
1162    #[test]
1163    fn http_error_kind_exhaustive_match() {
1164        let k = HttpErrorKind::Other;
1165        match k {
1166            HttpErrorKind::Timeout => {}
1167            HttpErrorKind::Connect => {}
1168            HttpErrorKind::Redirect => {}
1169            HttpErrorKind::Status(_) => {}
1170            HttpErrorKind::RequestBody => {}
1171            HttpErrorKind::ResponseBody => {}
1172            HttpErrorKind::Decode => {}
1173            HttpErrorKind::Builder => {}
1174            HttpErrorKind::Other => {}
1175        }
1176    }
1177
1178    /// bd:JMAP-6r7c.34 — exhaustive match over WebSocketErrorKind. Same
1179    /// regression-tripwire role as http_error_kind_exhaustive_match.
1180    #[test]
1181    fn ws_error_kind_exhaustive_match() {
1182        let k = WebSocketErrorKind::Other;
1183        match k {
1184            WebSocketErrorKind::ConnectionClosed => {}
1185            WebSocketErrorKind::AlreadyClosed => {}
1186            WebSocketErrorKind::Url => {}
1187            WebSocketErrorKind::Protocol => {}
1188            WebSocketErrorKind::Capacity => {}
1189            WebSocketErrorKind::Io => {}
1190            WebSocketErrorKind::Other => {}
1191        }
1192    }
1193
1194    /// bd:JMAP-6r7c.34 — independent oracle for the ConnectionClosed
1195    /// classification. tungstenite::Error::ConnectionClosed is a unit
1196    /// variant constructible directly; the test wraps it through
1197    /// WebSocketError::from and asserts kind() == ConnectionClosed.
1198    #[test]
1199    fn ws_error_kind_classifies_connection_closed() {
1200        let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
1201        let ws = WebSocketError(inner);
1202        assert_eq!(ws.kind(), WebSocketErrorKind::ConnectionClosed);
1203    }
1204
1205    /// bd:JMAP-6r7c.34 — independent oracle for the Io classification.
1206    /// std::io::Error is constructible directly and tungstenite::Error
1207    /// has a `From<std::io::Error>` impl that produces the Io variant.
1208    #[test]
1209    fn ws_error_kind_classifies_io() {
1210        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test");
1211        let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
1212        let ws = WebSocketError(inner);
1213        assert_eq!(ws.kind(), WebSocketErrorKind::Io);
1214    }
1215
1216    /// bd:JMAP-6r7c.34 — independent oracle for the Builder classification.
1217    /// reqwest::Client::get on a malformed URL produces a builder-time
1218    /// error; the test sends through HttpError::from(reqwest::Error) and
1219    /// asserts kind() == Builder. The test does NOT use HttpError's own
1220    /// is_builder() as the oracle — it uses reqwest's invariant that
1221    /// "an unparseable URL produces a builder error" as the independent
1222    /// claim, and asserts the wrapper preserves the classification.
1223    #[test]
1224    fn http_error_kind_classifies_builder_error() {
1225        // reqwest::ClientBuilder::new().build() then .get on a malformed
1226        // URL is the canonical "builder error" production path. The
1227        // empty string is rejected as not-a-URL.
1228        let client = reqwest::ClientBuilder::new().build().expect("build");
1229        let req_err = client
1230            .request(reqwest::Method::GET, "not a url")
1231            .build()
1232            .expect_err("malformed URL must produce a request builder error");
1233        // Independent oracle: reqwest documents that URL parse failures
1234        // during build come back as is_builder() == true.
1235        assert!(
1236            req_err.is_builder(),
1237            "reqwest invariant: malformed-URL build is a builder error"
1238        );
1239
1240        let http = HttpError(req_err);
1241        assert_eq!(
1242            http.kind(),
1243            HttpErrorKind::Builder,
1244            "HttpError::kind must classify a builder-side error as Builder"
1245        );
1246    }
1247
1248    /// `now` for the Retry-After tests: 2014-10-30T06:12:00Z, the RFC 8620
1249    /// §1.4 example instant (epoch value from `date -u -d @1414649520`).
1250    fn rfc8620_now() -> SystemTime {
1251        UNIX_EPOCH + std::time::Duration::from_secs(1_414_649_520)
1252    }
1253
1254    fn retry_after_of(e: ClientError) -> String {
1255        match e {
1256            ClientError::RateLimited { retry_after } => retry_after.into_inner(),
1257            other => panic!("expected RateLimited, got {other:?}"),
1258        }
1259    }
1260
1261    /// RFC 9110 §5.6.7: "A recipient that parses a timestamp value in an
1262    /// HTTP field MUST accept all three HTTP-date formats." The section's
1263    /// examples are "Sun, 06 Nov 1994 08:49:37 GMT" (IMF-fixdate),
1264    /// "Sunday, 06-Nov-94 08:49:37 GMT" (RFC 850) and
1265    /// "Sun Nov  6 08:49:37 1994" (asctime), all the same instant.
1266    #[test]
1267    fn rate_limited_accepts_all_three_http_date_formats() {
1268        for v in [
1269            "Sun, 06 Nov 1994 08:49:37 GMT",
1270            "Sunday, 06-Nov-94 08:49:37 GMT",
1271            "Sun Nov  6 08:49:37 1994",
1272        ] {
1273            assert_eq!(
1274                retry_after_of(ClientError::rate_limited(Some(v), rfc8620_now())),
1275                "1994-11-06T08:49:37Z",
1276                "Retry-After: {v}"
1277            );
1278        }
1279    }
1280
1281    /// RFC 9110 §10.2.3 examples: "Retry-After: Fri, 31 Dec 1999 23:59:59
1282    /// GMT" and "Retry-After: 120 ... In the latter example, the delay is
1283    /// 2 minutes."
1284    #[test]
1285    fn rate_limited_parses_rfc9110_retry_after_examples() {
1286        assert_eq!(
1287            retry_after_of(ClientError::rate_limited(
1288                Some("Fri, 31 Dec 1999 23:59:59 GMT"),
1289                rfc8620_now()
1290            )),
1291            "1999-12-31T23:59:59Z"
1292        );
1293        assert_eq!(
1294            retry_after_of(ClientError::rate_limited(Some("120"), rfc8620_now())),
1295            "2014-10-30T06:14:00Z"
1296        );
1297    }
1298
1299    /// RFC 9110 §10.2.3: `delay-seconds = 1*DIGIT`, so "+120" and "" are
1300    /// neither delay-seconds nor HTTP-date; with no usable value the
1301    /// documented fallback is the receive time.
1302    #[test]
1303    fn rate_limited_falls_back_to_now_without_usable_value() {
1304        for v in [None, Some(""), Some("+120"), Some("soon")] {
1305            assert_eq!(
1306                retry_after_of(ClientError::rate_limited(v, rfc8620_now())),
1307                "2014-10-30T06:12:00Z",
1308                "Retry-After: {v:?}"
1309            );
1310        }
1311    }
1312
1313    /// RFC 8620 §1.4 UTCDate has a four-digit year; a delay-seconds value
1314    /// overflowing u64 clamps to 9999-12-31T23:59:59Z
1315    /// (`date -u -d @253402300799`).
1316    #[test]
1317    fn rate_limited_clamps_huge_delay() {
1318        assert_eq!(
1319            retry_after_of(ClientError::rate_limited(
1320                Some("99999999999999999999999"),
1321                rfc8620_now()
1322            )),
1323            "9999-12-31T23:59:59Z"
1324        );
1325    }
1326
1327    /// Cross-validation against the independent `days_from_civil`
1328    /// implementation in `jmap_types::UTCDate::to_epoch_seconds`, plus
1329    /// fixed vectors from GNU `date -u -d @N`: 951825600 is
1330    /// 2000-02-29T12:00:00Z (a 400-year leap day) and 784111777 is
1331    /// 1994-11-06T08:49:37Z.
1332    #[test]
1333    fn utcdate_from_epoch_matches_independent_inverse() {
1334        assert_eq!(utcdate_from_epoch(951_825_600), "2000-02-29T12:00:00Z");
1335        assert_eq!(utcdate_from_epoch(784_111_777), "1994-11-06T08:49:37Z");
1336        // Step by a prime so every time-of-day and calendar position is hit.
1337        let mut s = 0u64;
1338        while s <= 253_402_300_799 {
1339            let d = utcdate_from_epoch(s);
1340            let parsed = jmap_types::UTCDate::new_validated(d.as_ref())
1341                .unwrap_or_else(|e| panic!("{d} invalid: {e}"));
1342            assert_eq!(parsed.to_epoch_seconds().unwrap(), s as i64, "{d}");
1343            s += 9_999_991;
1344        }
1345    }
1346}