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;
93
94// ---------------------------------------------------------------------------
95// HttpError — opaque wrapper around reqwest::Error
96// ---------------------------------------------------------------------------
97
98/// HTTP transport error reported by the underlying HTTP client.
99///
100/// The inner third-party error type is private; callers diagnose the failure
101/// via the accessor methods, all of which return primitive types so this
102/// crate can swap or bump the underlying HTTP client without breaking the
103/// public API.
104#[non_exhaustive]
105pub struct HttpError(reqwest::Error);
106
107impl HttpError {
108    /// `true` if the request timed out before a response was received.
109    pub fn is_timeout(&self) -> bool {
110        self.0.is_timeout()
111    }
112    /// `true` if the underlying connection could not be established
113    /// (DNS failure, TCP refused, TLS handshake failure, etc.).
114    pub fn is_connect(&self) -> bool {
115        self.0.is_connect()
116    }
117    /// `true` if the error originated in the request builder
118    /// (URL parse failure, invalid header construction at build time, etc.).
119    pub fn is_builder(&self) -> bool {
120        self.0.is_builder()
121    }
122    /// `true` if the error is a redirect-loop or too-many-redirects failure.
123    pub fn is_redirect(&self) -> bool {
124        self.0.is_redirect()
125    }
126    /// `true` if the error originated from a non-success HTTP status.
127    pub fn is_status(&self) -> bool {
128        self.0.is_status()
129    }
130    /// `true` if the error happened while sending the request body.
131    pub fn is_request(&self) -> bool {
132        self.0.is_request()
133    }
134    /// `true` if the error happened while receiving / decoding the response body.
135    pub fn is_body(&self) -> bool {
136        self.0.is_body()
137    }
138    /// `true` if the response body could not be decoded as the requested
139    /// representation (e.g. JSON parse failure inside the transport layer).
140    pub fn is_decode(&self) -> bool {
141        self.0.is_decode()
142    }
143    /// HTTP status code if the error came from a non-success response;
144    /// `None` for transport-level failures (timeout, connection refused, etc.).
145    pub fn status(&self) -> Option<u16> {
146        self.0.status().map(|s| s.as_u16())
147    }
148    /// URL the request was sent to, if known. Returned as an owned `String`
149    /// to avoid leaking the underlying transport's `Url` type into this
150    /// crate's public API.
151    pub fn url(&self) -> Option<String> {
152        self.0.url().map(ToString::to_string)
153    }
154
155    /// Classify the error into a single category (bd:JMAP-6r7c.34).
156    ///
157    /// The 8 [`is_*`](HttpError::is_timeout) boolean accessors are
158    /// useful when a caller wants to test for a specific category, but
159    /// they leave the caller writing a chained-`if-else` dispatch with
160    /// undocumented mutual relationships ("can `is_status` and
161    /// `is_decode` both be true?"). This method returns a single
162    /// `HttpErrorKind` so a caller can `match` on it once.
163    ///
164    /// Precedence (highest first) when multiple predicates could
165    /// arguably apply: `Timeout`, `Connect`, `Redirect`, `Status`,
166    /// `RequestBody`, `ResponseBody`, `Decode`, `Builder`, `Other`.
167    /// Status takes precedence over body/decode because reqwest sets
168    /// `is_status` precisely when the failure is "the HTTP server
169    /// returned a non-success status code"; in that case the body or
170    /// decode flag may also be set, but the most-actionable
171    /// classification for a caller is the status code itself.
172    ///
173    /// This method does not return retriability advice — the same
174    /// `Status(429)` may be retriable or fatal depending on the
175    /// `Retry-After` header value, and the same `Connect` may mean
176    /// "DNS not yet warm" (retry) or "host is down" (give up). Make
177    /// the retriability decision at the call site, using the kind as
178    /// input.
179    pub fn kind(&self) -> HttpErrorKind {
180        if self.0.is_timeout() {
181            HttpErrorKind::Timeout
182        } else if self.0.is_connect() {
183            HttpErrorKind::Connect
184        } else if self.0.is_redirect() {
185            HttpErrorKind::Redirect
186        } else if let Some(s) = self.0.status() {
187            HttpErrorKind::Status(s.as_u16())
188        } else if self.0.is_request() {
189            HttpErrorKind::RequestBody
190        } else if self.0.is_body() {
191            HttpErrorKind::ResponseBody
192        } else if self.0.is_decode() {
193            HttpErrorKind::Decode
194        } else if self.0.is_builder() {
195            HttpErrorKind::Builder
196        } else {
197            HttpErrorKind::Other
198        }
199    }
200}
201
202/// Classification of an [`HttpError`] returned by [`HttpError::kind`].
203///
204/// A coarse partition over the failure modes reqwest reports. Use this
205/// when a caller wants to dispatch on the error category in a single
206/// `match`; the [`HttpError::is_timeout`] / [`HttpError::is_connect`]
207/// boolean accessors remain available for callers that test for one
208/// specific category.
209///
210/// `#[non_exhaustive]` so new variants may be added in minor releases
211/// without breaking callers.
212#[non_exhaustive]
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214pub enum HttpErrorKind {
215    /// Request timed out before a response was received
216    /// ([`HttpError::is_timeout`]).
217    Timeout,
218    /// Underlying connection could not be established — DNS failure,
219    /// TCP refused, TLS handshake failure ([`HttpError::is_connect`]).
220    Connect,
221    /// Redirect loop or too-many-redirects failure
222    /// ([`HttpError::is_redirect`]).
223    Redirect,
224    /// Server returned a non-success HTTP status. The payload is the
225    /// status code as `u16` (e.g. `404`, `429`, `503`). 401 / 403 are
226    /// not surfaced here — they are caught earlier and produce
227    /// [`ClientError::AuthFailed`].
228    Status(u16),
229    /// Error happened while sending the request body
230    /// ([`HttpError::is_request`]).
231    RequestBody,
232    /// Error happened while receiving the response body
233    /// ([`HttpError::is_body`]).
234    ResponseBody,
235    /// Response body could not be decoded as the requested representation
236    /// (e.g. JSON parse failure inside the transport layer)
237    /// ([`HttpError::is_decode`]).
238    Decode,
239    /// Error originated in the request builder — URL parse failure,
240    /// invalid header construction at build time, etc.
241    /// ([`HttpError::is_builder`]). Indicates a caller bug.
242    Builder,
243    /// Categorisation did not match any of the predicates above.
244    /// May appear for transport-level errors that reqwest reports
245    /// without setting any of the typed predicates.
246    Other,
247}
248
249impl fmt::Display for HttpError {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        fmt::Display::fmt(&self.0, f)
252    }
253}
254
255impl fmt::Debug for HttpError {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.debug_tuple("HttpError").field(&self.0).finish()
258    }
259}
260
261impl StdError for HttpError {
262    fn source(&self) -> Option<&(dyn StdError + 'static)> {
263        Some(&self.0)
264    }
265}
266
267// ---------------------------------------------------------------------------
268// WebSocketError — opaque wrapper around tokio_tungstenite::tungstenite::Error
269// ---------------------------------------------------------------------------
270
271/// WebSocket transport error reported by the underlying WebSocket client.
272///
273/// As with [`HttpError`], the inner third-party type is private and
274/// diagnostics are exposed via accessor methods returning primitive types.
275#[non_exhaustive]
276pub struct WebSocketError(tokio_tungstenite::tungstenite::Error);
277
278impl WebSocketError {
279    /// `true` if the peer cleanly closed the connection.
280    pub fn is_connection_closed(&self) -> bool {
281        matches!(
282            &self.0,
283            tokio_tungstenite::tungstenite::Error::ConnectionClosed
284        )
285    }
286    /// `true` if the connection was already closed when the operation was
287    /// attempted (caller bug or race).
288    pub fn is_already_closed(&self) -> bool {
289        matches!(
290            &self.0,
291            tokio_tungstenite::tungstenite::Error::AlreadyClosed
292        )
293    }
294    /// `true` if the error wraps an underlying `std::io::Error`.
295    pub fn is_io(&self) -> bool {
296        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Io(_))
297    }
298    /// `true` if the error is a WebSocket protocol violation
299    /// (malformed frame, invalid opcode, etc.).
300    pub fn is_protocol(&self) -> bool {
301        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Protocol(_))
302    }
303    /// `true` if a frame or message exceeded a configured size limit.
304    pub fn is_capacity(&self) -> bool {
305        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Capacity(_))
306    }
307    /// `true` if the WebSocket URL was invalid.
308    pub fn is_url(&self) -> bool {
309        matches!(&self.0, tokio_tungstenite::tungstenite::Error::Url(_))
310    }
311
312    /// Classify the error into a single category (bd:JMAP-6r7c.34).
313    ///
314    /// Single-`match` alternative to the 6 [`is_*`](WebSocketError::is_io)
315    /// boolean accessors. Returns a [`WebSocketErrorKind`] so a caller
316    /// can dispatch on the failure mode without chained-`if-else`.
317    ///
318    /// Precedence (highest first): `ConnectionClosed`, `AlreadyClosed`,
319    /// `Url`, `Protocol`, `Capacity`, `Io`, `Other`. The first three
320    /// are exact tungstenite variants and are mutually exclusive;
321    /// the remainder follow the `is_*` accessor order from this file.
322    /// This method does not return retriability advice — make that
323    /// decision at the call site using the kind as input.
324    pub fn kind(&self) -> WebSocketErrorKind {
325        use tokio_tungstenite::tungstenite::Error as TError;
326        match &self.0 {
327            TError::ConnectionClosed => WebSocketErrorKind::ConnectionClosed,
328            TError::AlreadyClosed => WebSocketErrorKind::AlreadyClosed,
329            TError::Url(_) => WebSocketErrorKind::Url,
330            TError::Protocol(_) => WebSocketErrorKind::Protocol,
331            TError::Capacity(_) => WebSocketErrorKind::Capacity,
332            TError::Io(_) => WebSocketErrorKind::Io,
333            _ => WebSocketErrorKind::Other,
334        }
335    }
336}
337
338/// Classification of a [`WebSocketError`] returned by [`WebSocketError::kind`].
339///
340/// `#[non_exhaustive]` so new variants may be added in minor releases
341/// without breaking callers.
342#[non_exhaustive]
343#[derive(Debug, Clone, PartialEq, Eq, Hash)]
344pub enum WebSocketErrorKind {
345    /// Peer cleanly closed the connection
346    /// ([`WebSocketError::is_connection_closed`]).
347    ConnectionClosed,
348    /// Connection was already closed when the operation was attempted
349    /// — caller bug or race ([`WebSocketError::is_already_closed`]).
350    AlreadyClosed,
351    /// WebSocket URL was invalid ([`WebSocketError::is_url`]).
352    Url,
353    /// WebSocket protocol violation — malformed frame, invalid opcode,
354    /// etc. ([`WebSocketError::is_protocol`]).
355    Protocol,
356    /// Frame or message exceeded a configured size limit
357    /// ([`WebSocketError::is_capacity`]).
358    Capacity,
359    /// Error wraps an underlying `std::io::Error`
360    /// ([`WebSocketError::is_io`]).
361    Io,
362    /// Categorisation did not match any of the variants above. May
363    /// appear for tungstenite error variants not yet covered by this
364    /// crate's classification (e.g. `Tls`, `Http`, future additions).
365    Other,
366}
367
368impl fmt::Display for WebSocketError {
369    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370        fmt::Display::fmt(&self.0, f)
371    }
372}
373
374impl fmt::Debug for WebSocketError {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        f.debug_tuple("WebSocketError").field(&self.0).finish()
377    }
378}
379
380impl StdError for WebSocketError {
381    fn source(&self) -> Option<&(dyn StdError + 'static)> {
382        Some(&self.0)
383    }
384}
385
386// ---------------------------------------------------------------------------
387// InvalidHeaderValueError — string-only wrapper, no third-party leak
388// ---------------------------------------------------------------------------
389
390/// A header value (typically an authentication token) contained bytes that
391/// are not valid for an HTTP header.
392///
393/// The inner type is just a string message; there is no actionable
394/// diagnostic state beyond that, so this wrapper does not expose any
395/// accessor beyond [`Display`](fmt::Display).
396#[non_exhaustive]
397pub struct InvalidHeaderValueError {
398    message: String,
399}
400
401impl InvalidHeaderValueError {
402    /// The human-readable description of the failure.
403    pub fn message(&self) -> &str {
404        &self.message
405    }
406}
407
408impl fmt::Display for InvalidHeaderValueError {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        f.write_str(&self.message)
411    }
412}
413
414impl fmt::Debug for InvalidHeaderValueError {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        f.debug_struct("InvalidHeaderValueError")
417            .field("message", &self.message)
418            .finish()
419    }
420}
421
422impl StdError for InvalidHeaderValueError {}
423
424// ---------------------------------------------------------------------------
425// ParseError / SerializeError — opaque wrappers around serde_json::Error
426// ---------------------------------------------------------------------------
427
428/// JSON deserialize / parse error reported by the underlying JSON parser.
429///
430/// The inner third-party error type is private; callers diagnose the
431/// failure via the accessor methods on this wrapper, all of which return
432/// primitive types or the workspace-local [`ParseCategory`] enum. The
433/// wrapper exists so that the [`ClientError::Parse`] variant payload is
434/// insulated from a future JSON-parser swap (bd:JMAP-6r7c.26).
435///
436/// Construct via [`ClientError::from_parse`] (the common path) or
437/// [`ParseError::from_serde_json`] (the low-level path that returns a
438/// wrapper without classifying it into a [`ClientError`] variant).
439#[non_exhaustive]
440pub struct ParseError(serde_json::Error);
441
442impl ParseError {
443    /// Wrap a [`serde_json::Error`] into a [`ParseError`].
444    ///
445    /// Most callers want [`ClientError::from_parse`] which wraps and
446    /// classifies in one step; this lower-level constructor is for
447    /// callers that need to hold a [`ParseError`] before deciding
448    /// whether to surface it as [`ClientError::Parse`] or in some
449    /// other variant.
450    pub fn from_serde_json(e: serde_json::Error) -> Self {
451        Self(e)
452    }
453
454    /// One-based line number where parsing failed, or `0` if the parser
455    /// does not report a line number for this error category.
456    pub fn line(&self) -> usize {
457        self.0.line()
458    }
459
460    /// One-based column number where parsing failed, or `0` if the parser
461    /// does not report a column number for this error category.
462    pub fn column(&self) -> usize {
463        self.0.column()
464    }
465
466    /// Coarse-grained category of the failure.
467    ///
468    /// See [`ParseCategory`] for the variant set and what each one means.
469    pub fn classify(&self) -> ParseCategory {
470        ParseCategory::from(self.0.classify())
471    }
472}
473
474impl fmt::Display for ParseError {
475    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476        fmt::Display::fmt(&self.0, f)
477    }
478}
479
480impl fmt::Debug for ParseError {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        f.debug_tuple("ParseError").field(&self.0).finish()
483    }
484}
485
486impl StdError for ParseError {
487    fn source(&self) -> Option<&(dyn StdError + 'static)> {
488        Some(&self.0)
489    }
490}
491
492/// JSON serialize error reported by the underlying JSON serializer.
493///
494/// Shape mirrors [`ParseError`]; see that type for the SemVer-isolation
495/// rationale. Indicates a caller bug — the data structure passed to a
496/// serializer contains non-serializable values (`f64::NAN`, a map keyed
497/// by a non-stringifiable type, etc.).
498#[non_exhaustive]
499pub struct SerializeError(serde_json::Error);
500
501impl SerializeError {
502    /// Wrap a [`serde_json::Error`] into a [`SerializeError`].
503    ///
504    /// Most callers want [`ClientError::from_serialize`] which wraps and
505    /// classifies in one step.
506    pub fn from_serde_json(e: serde_json::Error) -> Self {
507        Self(e)
508    }
509
510    /// One-based line number where serialization failed, or `0` if the
511    /// serializer does not report a line for this category.
512    pub fn line(&self) -> usize {
513        self.0.line()
514    }
515
516    /// One-based column number where serialization failed, or `0` if the
517    /// serializer does not report a column for this category.
518    pub fn column(&self) -> usize {
519        self.0.column()
520    }
521
522    /// Coarse-grained category of the failure.
523    pub fn classify(&self) -> ParseCategory {
524        ParseCategory::from(self.0.classify())
525    }
526}
527
528impl fmt::Display for SerializeError {
529    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530        fmt::Display::fmt(&self.0, f)
531    }
532}
533
534impl fmt::Debug for SerializeError {
535    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536        f.debug_tuple("SerializeError").field(&self.0).finish()
537    }
538}
539
540impl StdError for SerializeError {
541    fn source(&self) -> Option<&(dyn StdError + 'static)> {
542        Some(&self.0)
543    }
544}
545
546/// Classification of a [`ParseError`] / [`SerializeError`] failure.
547///
548/// Maps onto the four `serde_json::error::Category` variants without
549/// re-exporting the third-party enum; the workspace-local enum is what
550/// callers pattern-match on, insulating them from a future JSON-parser
551/// swap.
552#[non_exhaustive]
553#[derive(Debug, Clone, PartialEq, Eq, Hash)]
554pub enum ParseCategory {
555    /// The error was caused by a failure to read or write bytes on an
556    /// I/O stream.
557    Io,
558    /// The error was caused by input that was not syntactically valid
559    /// JSON.
560    Syntax,
561    /// The error was caused by input data that was semantically incorrect
562    /// (e.g. a JSON value of the wrong type for the target Rust type).
563    Data,
564    /// The error was caused by prematurely-terminated input.
565    Eof,
566}
567
568impl From<serde_json::error::Category> for ParseCategory {
569    fn from(cat: serde_json::error::Category) -> Self {
570        match cat {
571            serde_json::error::Category::Io => Self::Io,
572            serde_json::error::Category::Syntax => Self::Syntax,
573            serde_json::error::Category::Data => Self::Data,
574            serde_json::error::Category::Eof => Self::Eof,
575        }
576    }
577}
578
579// ---------------------------------------------------------------------------
580// ClientError
581// ---------------------------------------------------------------------------
582
583/// Errors produced by the base JMAP client.
584///
585/// Variants cover transport failures (`Http`, `WebSocket`), authentication
586/// (`AuthFailed`), JMAP protocol errors (`MethodError`, `UnexpectedResponse`),
587/// caller bugs (`InvalidArgument`, `InvalidHeaderValue`, `Serialize`), and
588/// resource-exhaustion guards (`ResponseTooLarge`, `SseFrameTooLarge`).
589///
590/// Marked `#[non_exhaustive]` so additional variants may be introduced in
591/// minor releases. See per-variant documentation for retriability guidance.
592#[non_exhaustive]
593#[derive(Debug, thiserror::Error)]
594pub enum ClientError {
595    /// Network or TLS error from the HTTP layer. May be retriable (transient
596    /// network failure) or permanent (TLS configuration error). Indicates a
597    /// network or transport problem, not a JMAP protocol error.
598    ///
599    /// The payload is an opaque [`HttpError`] that does not expose any
600    /// third-party error type — this crate's HTTP transport can be swapped
601    /// or its major version bumped without affecting downstream callers.
602    /// Use [`HttpError::is_timeout`], [`HttpError::status`], etc. to diagnose.
603    #[error("HTTP error: {0}")]
604    Http(HttpError),
605
606    /// A header value could not be encoded. Indicates a caller bug — the
607    /// credential string contains characters that are not valid HTTP header
608    /// value characters. Not retriable.
609    #[error("invalid header value: {0}")]
610    InvalidHeaderValue(InvalidHeaderValueError),
611
612    /// The server returned HTTP 401 (authentication failure) or 403
613    /// (authorization failure — credentials present but insufficient). Not
614    /// retriable without correcting credentials.
615    #[error("authentication or authorization failure: HTTP {0}")]
616    AuthFailed(u16),
617
618    /// A server response could not be parsed or did not match the expected
619    /// shape. Indicates the server sent a malformed response. Not retriable
620    /// without a server fix.
621    ///
622    /// The payload is an opaque [`ParseError`] that does not expose the
623    /// underlying JSON parser's error type — the variant is insulated from
624    /// a future parser swap. Construct explicitly:
625    /// `.map_err(ClientError::from_parse)` (or
626    /// `.map_err(jmap_base_client::ClientError::from_parse)` from outside
627    /// the crate). Pattern-match via `ClientError::Parse(e) => e.line()`,
628    /// `e.column()`, `e.classify()` — see [`ParseError`].
629    #[error("parse error: {0}")]
630    Parse(ParseError),
631
632    /// Blob SHA-256 mismatch on upload or download. Indicates in-transit
633    /// corruption or a misbehaving server. Not retriable without re-fetching
634    /// metadata.
635    ///
636    /// # Field semantics across both call sites (bd:JMAP-6r7c.10)
637    ///
638    /// The same variant is emitted from both upload and download paths and
639    /// the role of each field is constant across paths:
640    ///
641    /// - `expected` is the **pre-stated digest** the client was comparing
642    ///   against — i.e. the value that should hold if the bytes are intact.
643    /// - `actual` is the **freshly-observed digest** the client just
644    ///   computed or just learned.
645    ///
646    /// The *source* of each value depends on which call produced the error:
647    ///
648    /// | Call site | `expected` | `actual` |
649    /// |---|---|---|
650    /// | [`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 |
651    /// | [`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 |
652    ///
653    /// Both digests are canonical 64-character lowercase hex (per
654    /// draft-atwood-jmap-cid-00 §2 ABNF).
655    #[error("blob integrity check failed: expected {expected}, got {actual}")]
656    BlobIntegrityMismatch {
657        /// Pre-stated SHA-256 hex digest the client was comparing against.
658        /// On upload, this is the client's own pre-upload computation; on
659        /// download, this is the caller-supplied
660        /// [`DownloadBlobParams::expected_sha256`](crate::DownloadBlobParams::expected_sha256)
661        /// (typed [`jmap_cid_types::Sha256`], guaranteed canonical lowercase).
662        expected: String,
663        /// Freshly-observed SHA-256 hex digest. On upload, this is the
664        /// server-reported digest from the upload response. On download,
665        /// this is the client's own digest over the received bytes.
666        actual: String,
667    },
668
669    /// A caller-supplied argument violates a precondition (e.g. empty token,
670    /// colon in BasicAuth username, missing required filter field).
671    #[error("invalid argument: {0}")]
672    InvalidArgument(String),
673
674    /// The JMAP Session object from the server was missing a required field.
675    /// Indicates a server-side bug or incompatible server. Not retriable.
676    #[error("invalid session: {0}")]
677    InvalidSession(String),
678
679    /// The JMAP API response did not contain the expected method call ID.
680    /// Indicates a server-side bug or unexpected response shape.
681    #[error("method not found in response: {0}")]
682    MethodNotFound(String),
683
684    /// The JMAP server returned a method-level error object (RFC 8620 §3.6).
685    /// Retriability depends on `error_type` (e.g. `serverFail` may be
686    /// retried; `invalidArguments` is not retriable).
687    ///
688    /// `description` is `None` when the server omits the optional description field.
689    #[error("JMAP method error: {error_type}")]
690    MethodError {
691        /// The `type` field of the JMAP method-level error object (RFC 8620 §3.6.2),
692        /// e.g. `"invalidArguments"`, `"serverFail"`, `"accountNotFound"`.
693        error_type: String,
694        /// Optional human-readable error description (RFC 8620 §3.6.2);
695        /// `None` when the server omits this field.
696        description: Option<String>,
697    },
698
699    /// A JMAP request could not be serialized to JSON when sending over
700    /// WebSocket. Indicates a caller bug — the data structure contains
701    /// non-serializable values. Not retriable.
702    ///
703    /// This error is only returned by [`WsSession::send_request`](crate::WsSession::send_request); the HTTP
704    /// `call()` path delegates serialization to reqwest, which surfaces
705    /// serialization failures as [`ClientError::Http`].
706    ///
707    /// The payload is an opaque [`SerializeError`] that does not expose the
708    /// underlying JSON serializer's error type. Construct explicitly:
709    /// `.map_err(ClientError::from_serialize)`. Pattern-match via
710    /// `ClientError::Serialize(e) => e.line()`, `e.column()`, `e.classify()`
711    /// — see [`SerializeError`].
712    #[error("serialization error: {0}")]
713    Serialize(SerializeError),
714
715    /// An SSE frame exceeded the configured buffer limit
716    /// ([`ClientConfig::max_sse_frame`](crate::ClientConfig::max_sse_frame)). The stream is terminated after this
717    /// error. Indicates a misbehaving or hostile server.
718    #[error("SSE frame too large (limit: {limit} bytes)")]
719    SseFrameTooLarge {
720        /// The configured per-frame buffer cap (in bytes) that was exceeded.
721        limit: usize,
722    },
723
724    /// A server response body exceeded the enforced size limit. Protects
725    /// against unbounded memory allocation from malicious or buggy servers.
726    /// `actual` is in bytes (from Content-Length or actual read size).
727    #[error("response too large: {actual} bytes exceeds limit of {limit} bytes")]
728    ResponseTooLarge {
729        /// Observed response size in bytes (from `Content-Length` or the
730        /// running total of bytes read so far when streaming).
731        actual: u64,
732        /// Configured maximum response size in bytes.
733        limit: u64,
734    },
735
736    /// A WebSocket transport error (connection, framing, or TLS). May be
737    /// retriable (transient network failure) or permanent (TLS config error).
738    ///
739    /// The payload is an opaque [`WebSocketError`] that does not expose any
740    /// third-party error type — see [`HttpError`] for the same SemVer
741    /// rationale. Use [`WebSocketError::is_io`],
742    /// [`WebSocketError::is_protocol`], etc. to diagnose.
743    #[error("WebSocket error: {0}")]
744    WebSocket(WebSocketError),
745
746    /// The server returned a response that violates the JMAP protocol (outside
747    /// the Session fetch path). Examples: wrong `Content-Type` on an SSE
748    /// connection, unexpected response shape on a non-session endpoint.
749    ///
750    /// Distinct from [`ClientError::InvalidSession`], which indicates a
751    /// problem with the Session document itself. Not retriable without a
752    /// server fix.
753    #[error("unexpected server response: {0}")]
754    UnexpectedResponse(String),
755
756    /// Server rate-limited the request. `retry_after` indicates when to retry.
757    ///
758    /// # ⚠ Not currently produced by this crate (bd:JMAP-6lsm.3, bd:JMAP-6r7c.33)
759    ///
760    /// **HTTP 429 responses fall through `reqwest::error_for_status()` and
761    /// surface as [`ClientError::Http`] today, NOT as `ClientError::RateLimited`.**
762    /// A caller that matches only on this variant will miss every actual
763    /// 429 from the base crate. Until bd:JMAP-6lsm.3 lands the native
764    /// 429 → `RateLimited` conversion, callers MUST handle both cases:
765    ///
766    /// ```rust,ignore
767    /// match err {
768    ///     ClientError::RateLimited { retry_after } => {
769    ///         // Eventually the only path; today only produced by extension
770    ///         // crates that wrap this crate's error conversion.
771    ///         sleep_until(retry_after).await;
772    ///     }
773    ///     ClientError::Http(http) if http.status() == Some(429) => {
774    ///         // Base-crate path today (bd:JMAP-6lsm.3 will collapse this
775    ///         // into the RateLimited arm above).
776    ///         sleep(Duration::from_secs(30)).await; // or parse Retry-After
777    ///     }
778    ///     other => { /* propagate */ }
779    /// }
780    /// ```
781    ///
782    /// # Why the variant ships anyway
783    ///
784    /// The variant is part of the public contract so:
785    ///
786    /// 1. Extension crates that wrap or replace this crate's transport may
787    ///    detect 429 + parse `Retry-After` themselves and produce
788    ///    `RateLimited` from their own error-conversion code.
789    /// 2. Callers that want to handle rate limiting via this typed variant
790    ///    have a stable target to match on, even before the conversion
791    ///    logic lands here (tracked under `bd:JMAP-6lsm.3`).
792    ///
793    /// The variant shape will not change in a backward-incompatible way
794    /// when 429 → `RateLimited` conversion lands — it is part of a
795    /// `#[non_exhaustive]` enum and the struct payload is itself stable,
796    /// so callers writing the dual-match pattern above today will not
797    /// need to adjust when the migration completes.
798    #[error("rate limited; retry after {retry_after}")]
799    RateLimited {
800        /// Absolute UTC instant the client should wait until before retrying,
801        /// parsed from the `Retry-After` HTTP header (RFC 9110 §10.2.3).
802        retry_after: jmap_types::UTCDate,
803    },
804}
805
806impl ClientError {
807    /// Convert a [`reqwest::Error`] into a [`ClientError::Http`] variant.
808    ///
809    /// `pub(crate)` so downstream callers cannot construct transport-error
810    /// variants — that responsibility belongs to this crate's transport
811    /// layer alone. This is the only conversion path from the third-party
812    /// type into `ClientError`, and is the reason this crate's public API
813    /// no longer mentions `reqwest::Error`.
814    pub(crate) fn from_reqwest(e: reqwest::Error) -> Self {
815        Self::Http(HttpError(e))
816    }
817
818    /// Convert a [`tokio_tungstenite::tungstenite::Error`] into a
819    /// [`ClientError::WebSocket`] variant. See
820    /// [`from_reqwest`](Self::from_reqwest) for the SemVer rationale.
821    pub(crate) fn from_ws(e: tokio_tungstenite::tungstenite::Error) -> Self {
822        Self::WebSocket(WebSocketError(e))
823    }
824
825    /// Convert a [`reqwest::header::InvalidHeaderValue`] into a
826    /// [`ClientError::InvalidHeaderValue`] variant. The inner third-party
827    /// type carries no actionable diagnostic state, so we keep only the
828    /// `Display` representation as a `String`.
829    pub(crate) fn from_invalid_header(e: reqwest::header::InvalidHeaderValue) -> Self {
830        Self::InvalidHeaderValue(InvalidHeaderValueError {
831            message: e.to_string(),
832        })
833    }
834
835    /// Convert a [`serde_json::Error`] from a deserialize / parse step
836    /// into a [`ClientError::Parse`] variant carrying an opaque
837    /// [`ParseError`] payload (bd:JMAP-6r7c.26).
838    ///
839    /// Public because extension client crates (`jmap-mail-client`,
840    /// `jmap-chat-client`, etc.) need to surface their own JSON parse
841    /// failures as [`ClientError::Parse`]. Use in `.map_err`:
842    ///
843    /// ```rust,ignore
844    /// let val: T = serde_json::from_slice(&bytes)
845    ///     .map_err(jmap_base_client::ClientError::from_parse)?;
846    /// ```
847    ///
848    /// A future JSON-parser swap would deprecate this constructor in
849    /// favor of an analogous one for the new parser; the variant
850    /// payload type ([`ParseError`]) stays stable.
851    pub fn from_parse(e: serde_json::Error) -> Self {
852        Self::Parse(ParseError(e))
853    }
854
855    /// Convert a [`serde_json::Error`] from a serialize step into a
856    /// [`ClientError::Serialize`] variant carrying an opaque
857    /// [`SerializeError`] payload (bd:JMAP-6r7c.26).
858    ///
859    /// Public for the same reason as [`from_parse`](Self::from_parse).
860    pub fn from_serialize(e: serde_json::Error) -> Self {
861        Self::Serialize(SerializeError(e))
862    }
863}
864
865// ---------------------------------------------------------------------------
866// Tests
867// ---------------------------------------------------------------------------
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    /// Verify ClientError variants by exhaustive match. Variant names are
874    /// part of the public API; this catches accidental rename / removal.
875    #[test]
876    fn client_error_exhaustive_match() {
877        let e = ClientError::InvalidArgument("test".into());
878        match e {
879            ClientError::Http(_) => {}
880            ClientError::InvalidHeaderValue(_) => {}
881            ClientError::AuthFailed(_) => {}
882            ClientError::Parse(_) => {}
883            ClientError::BlobIntegrityMismatch { .. } => {}
884            ClientError::InvalidArgument(_) => {}
885            ClientError::InvalidSession(_) => {}
886            ClientError::MethodNotFound(_) => {}
887            ClientError::MethodError { .. } => {}
888            ClientError::Serialize(_) => {}
889            ClientError::SseFrameTooLarge { .. } => {}
890            ClientError::ResponseTooLarge { .. } => {}
891            ClientError::WebSocket(_) => {}
892            ClientError::UnexpectedResponse(_) => {}
893            ClientError::RateLimited { .. } => {}
894        }
895    }
896
897    /// InvalidHeaderValueError preserves the underlying message so the
898    /// Display output matches the third-party error's Display verbatim —
899    /// callers that previously logged the `ClientError::InvalidHeaderValue`
900    /// variant see the same diagnostic text after the wrapper rename.
901    ///
902    /// Independent oracle: reqwest::header::InvalidHeaderValue is produced
903    /// by HeaderValue::from_str on bytes that are not valid header values
904    /// (e.g. embedded newline). The wrapper's Display is just the inner
905    /// type's Display.
906    #[test]
907    fn invalid_header_value_preserves_message() {
908        let inner_err = reqwest::header::HeaderValue::from_str("bad\nvalue")
909            .expect_err("newline must be rejected as a header value");
910        let inner_display = inner_err.to_string();
911
912        let ce = ClientError::from_invalid_header(inner_err);
913        let ClientError::InvalidHeaderValue(ihve) = &ce else {
914            panic!("must be InvalidHeaderValue variant, got {ce:?}");
915        };
916        assert_eq!(
917            ihve.message(),
918            inner_display,
919            "wrapper message must equal inner Display"
920        );
921        // Outer ClientError Display includes the prefix.
922        assert!(
923            ce.to_string().starts_with("invalid header value: "),
924            "ClientError Display must use the variant's #[error] prefix: {ce}"
925        );
926    }
927
928    /// An HttpError constructed from a reqwest builder failure exposes the
929    /// expected diagnostic accessor values: is_builder=true, status=None,
930    /// and a non-empty Display. Independent oracle: reqwest's documented
931    /// behaviour for invalid URLs (builder error, no status code).
932    #[test]
933    fn http_error_from_invalid_url_is_builder_error() {
934        // reqwest::Client::new().get("not a url") produces a builder error
935        // when the URL fails to parse. Building the request and calling
936        // .send() requires async context; .build() is synchronous and
937        // suffices to provoke a parse failure.
938        let client = reqwest::Client::new();
939        let build_err = client
940            .request(reqwest::Method::GET, "://not-a-url")
941            .build()
942            .expect_err("malformed URL must produce a build error");
943
944        let ce = ClientError::from_reqwest(build_err);
945        let ClientError::Http(http_err) = &ce else {
946            panic!("must be Http variant, got {ce:?}");
947        };
948        assert!(
949            http_err.is_builder(),
950            "malformed URL must be classified as a builder error"
951        );
952        assert!(
953            http_err.status().is_none(),
954            "builder errors carry no HTTP status"
955        );
956        assert!(
957            !http_err.is_timeout(),
958            "builder error must not classify as timeout"
959        );
960        assert!(
961            !http_err.is_connect(),
962            "builder error must not classify as connect"
963        );
964        assert!(
965            !http_err.to_string().is_empty(),
966            "Display must produce a non-empty diagnostic"
967        );
968    }
969
970    /// A WebSocketError wrapping ConnectionClosed correctly classifies via
971    /// its accessor methods. Independent oracle: tungstenite's documented
972    /// Error variants are matched directly via the matches! macro.
973    #[test]
974    fn websocket_error_classifies_connection_closed() {
975        let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
976        let ce = ClientError::from_ws(inner);
977        let ClientError::WebSocket(ws_err) = &ce else {
978            panic!("must be WebSocket variant, got {ce:?}");
979        };
980        assert!(ws_err.is_connection_closed());
981        assert!(!ws_err.is_already_closed());
982        assert!(!ws_err.is_io());
983        assert!(!ws_err.is_protocol());
984        assert!(!ws_err.is_capacity());
985    }
986
987    /// A WebSocketError wrapping an Io variant correctly classifies via
988    /// is_io. Independent oracle: tungstenite::Error::Io is the documented
989    /// wrapper for std::io::Error sources.
990    #[test]
991    fn websocket_error_classifies_io() {
992        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test");
993        let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
994        let ce = ClientError::from_ws(inner);
995        let ClientError::WebSocket(ws_err) = &ce else {
996            panic!("must be WebSocket variant, got {ce:?}");
997        };
998        assert!(ws_err.is_io());
999        assert!(!ws_err.is_connection_closed());
1000        assert!(!ws_err.is_already_closed());
1001    }
1002
1003    /// HttpError, WebSocketError, and InvalidHeaderValueError all implement
1004    /// std::error::Error. This is a regression guard against any future
1005    /// refactor that drops one of these impls (which would silently break
1006    /// downstream code that iterates the source chain via Error::source).
1007    #[test]
1008    fn wrapper_types_implement_std_error() {
1009        fn assert_error<E: StdError>() {}
1010        assert_error::<HttpError>();
1011        assert_error::<WebSocketError>();
1012        assert_error::<InvalidHeaderValueError>();
1013        assert_error::<ParseError>();
1014        assert_error::<SerializeError>();
1015    }
1016
1017    // bd:JMAP-6r7c.26 — wrapping serde_json::Error behind ParseError and
1018    // SerializeError. Independent oracles below: hand-crafted JSON inputs
1019    // whose serde_json::Error categorisation is well-documented.
1020
1021    /// `ClientError::from_parse` wraps a syntax-error JSON failure into a
1022    /// `ClientError::Parse` carrying a `ParseError` whose `classify()` reports
1023    /// `ParseCategory::Syntax`. Oracle: `{` alone is documented as a syntax
1024    /// failure by serde_json (premature `{` with no member).
1025    #[test]
1026    fn parse_error_classifies_syntax_failure() {
1027        let inner =
1028            serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
1029        let ce = ClientError::from_parse(inner);
1030        let ClientError::Parse(pe) = &ce else {
1031            panic!("must be Parse variant, got {ce:?}");
1032        };
1033        // Serde-json reports EOF for an unclosed object on a stream that
1034        // contains nothing after `{` — the input was prematurely
1035        // terminated. Either Syntax or Eof is acceptable here; both are
1036        // structural-parse failures. Lock both as valid outcomes so a
1037        // future serde_json upgrade that flips classification does not
1038        // wrongly fail this test.
1039        assert!(
1040            matches!(pe.classify(), ParseCategory::Syntax | ParseCategory::Eof),
1041            "expected Syntax or Eof, got {:?}",
1042            pe.classify()
1043        );
1044        // line/column are 1-based and non-zero for any parse failure
1045        // with a position; the unclosed `{` has a position.
1046        assert!(pe.line() > 0, "line must be 1-based and non-zero");
1047    }
1048
1049    /// `ClientError::from_parse` on a type-mismatch error reports
1050    /// `ParseCategory::Data`. Oracle: feeding `"not a number"` to
1051    /// `serde_json::from_str::<u32>` is documented as a Data error
1052    /// (semantic mismatch, not syntax).
1053    #[test]
1054    fn parse_error_classifies_data_failure() {
1055        let inner = serde_json::from_str::<u32>("\"not a number\"")
1056            .expect_err("string must fail to deserialise as u32");
1057        let ce = ClientError::from_parse(inner);
1058        let ClientError::Parse(pe) = &ce else {
1059            panic!("must be Parse variant, got {ce:?}");
1060        };
1061        assert_eq!(pe.classify(), ParseCategory::Data);
1062    }
1063
1064    /// `ClientError::from_serialize` wraps a `serde_json::Error` from a
1065    /// serialise step into a `ClientError::Serialize` carrying an opaque
1066    /// `SerializeError`. Oracle: a `BTreeMap` whose keys are a tuple
1067    /// type cannot serialise as JSON because JSON object keys MUST be
1068    /// strings — serde_json documents this as `Error::key_must_be_a_string`.
1069    #[test]
1070    fn serialize_error_wraps_non_string_map_key() {
1071        let mut map: std::collections::BTreeMap<(i32, i32), &str> =
1072            std::collections::BTreeMap::new();
1073        map.insert((1, 2), "value");
1074        let inner = serde_json::to_string(&map)
1075            .expect_err("tuple-keyed map must not serialise as a JSON object");
1076        let ce = ClientError::from_serialize(inner);
1077        let ClientError::Serialize(se) = &ce else {
1078            panic!("must be Serialize variant, got {ce:?}");
1079        };
1080        // Display surface is non-empty (the underlying serde_json::Error
1081        // produces a diagnostic).
1082        assert!(
1083            !se.to_string().is_empty(),
1084            "SerializeError Display must be non-empty"
1085        );
1086    }
1087
1088    /// `ParseError`'s `Debug` output formats as `ParseError(...)` with the
1089    /// inner serde_json::Error nested, NOT as a bare serde_json::Error.
1090    /// This locks in the wrapper-rename for any code that pattern-matches
1091    /// on Debug output (e.g. a snapshot test) without exposing the inner
1092    /// type as the top-level shape.
1093    #[test]
1094    fn parse_error_debug_format_uses_wrapper_name() {
1095        let inner =
1096            serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
1097        let ce = ClientError::from_parse(inner);
1098        let ClientError::Parse(pe) = &ce else {
1099            panic!("must be Parse variant");
1100        };
1101        let debug = format!("{pe:?}");
1102        assert!(
1103            debug.starts_with("ParseError("),
1104            "Debug must use the wrapper tuple-struct name: {debug}"
1105        );
1106    }
1107
1108    /// `SerializeError` Debug format mirror.
1109    #[test]
1110    fn serialize_error_debug_format_uses_wrapper_name() {
1111        let mut map: std::collections::BTreeMap<(i32, i32), &str> =
1112            std::collections::BTreeMap::new();
1113        map.insert((1, 2), "value");
1114        let inner = serde_json::to_string(&map)
1115            .expect_err("tuple-keyed map must not serialise as a JSON object");
1116        let ce = ClientError::from_serialize(inner);
1117        let ClientError::Serialize(se) = &ce else {
1118            panic!("must be Serialize variant");
1119        };
1120        let debug = format!("{se:?}");
1121        assert!(
1122            debug.starts_with("SerializeError("),
1123            "Debug must use the wrapper tuple-struct name: {debug}"
1124        );
1125    }
1126
1127    /// bd:JMAP-6r7c.34 — verify every HttpErrorKind variant by exhaustive
1128    /// match. A future variant addition (forgetting to update this match)
1129    /// fails the test, forcing a deliberate choice rather than silent
1130    /// API drift.
1131    #[test]
1132    fn http_error_kind_exhaustive_match() {
1133        let k = HttpErrorKind::Other;
1134        match k {
1135            HttpErrorKind::Timeout => {}
1136            HttpErrorKind::Connect => {}
1137            HttpErrorKind::Redirect => {}
1138            HttpErrorKind::Status(_) => {}
1139            HttpErrorKind::RequestBody => {}
1140            HttpErrorKind::ResponseBody => {}
1141            HttpErrorKind::Decode => {}
1142            HttpErrorKind::Builder => {}
1143            HttpErrorKind::Other => {}
1144        }
1145    }
1146
1147    /// bd:JMAP-6r7c.34 — exhaustive match over WebSocketErrorKind. Same
1148    /// regression-tripwire role as http_error_kind_exhaustive_match.
1149    #[test]
1150    fn ws_error_kind_exhaustive_match() {
1151        let k = WebSocketErrorKind::Other;
1152        match k {
1153            WebSocketErrorKind::ConnectionClosed => {}
1154            WebSocketErrorKind::AlreadyClosed => {}
1155            WebSocketErrorKind::Url => {}
1156            WebSocketErrorKind::Protocol => {}
1157            WebSocketErrorKind::Capacity => {}
1158            WebSocketErrorKind::Io => {}
1159            WebSocketErrorKind::Other => {}
1160        }
1161    }
1162
1163    /// bd:JMAP-6r7c.34 — independent oracle for the ConnectionClosed
1164    /// classification. tungstenite::Error::ConnectionClosed is a unit
1165    /// variant constructible directly; the test wraps it through
1166    /// WebSocketError::from and asserts kind() == ConnectionClosed.
1167    #[test]
1168    fn ws_error_kind_classifies_connection_closed() {
1169        let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
1170        let ws = WebSocketError(inner);
1171        assert_eq!(ws.kind(), WebSocketErrorKind::ConnectionClosed);
1172    }
1173
1174    /// bd:JMAP-6r7c.34 — independent oracle for the Io classification.
1175    /// std::io::Error is constructible directly and tungstenite::Error
1176    /// has a `From<std::io::Error>` impl that produces the Io variant.
1177    #[test]
1178    fn ws_error_kind_classifies_io() {
1179        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test");
1180        let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
1181        let ws = WebSocketError(inner);
1182        assert_eq!(ws.kind(), WebSocketErrorKind::Io);
1183    }
1184
1185    /// bd:JMAP-6r7c.34 — independent oracle for the Builder classification.
1186    /// reqwest::Client::get on a malformed URL produces a builder-time
1187    /// error; the test sends through HttpError::from(reqwest::Error) and
1188    /// asserts kind() == Builder. The test does NOT use HttpError's own
1189    /// is_builder() as the oracle — it uses reqwest's invariant that
1190    /// "an unparseable URL produces a builder error" as the independent
1191    /// claim, and asserts the wrapper preserves the classification.
1192    #[test]
1193    fn http_error_kind_classifies_builder_error() {
1194        // reqwest::ClientBuilder::new().build() then .get on a malformed
1195        // URL is the canonical "builder error" production path. The
1196        // empty string is rejected as not-a-URL.
1197        let client = reqwest::ClientBuilder::new().build().expect("build");
1198        let req_err = client
1199            .request(reqwest::Method::GET, "not a url")
1200            .build()
1201            .expect_err("malformed URL must produce a request builder error");
1202        // Independent oracle: reqwest documents that URL parse failures
1203        // during build come back as is_builder() == true.
1204        assert!(
1205            req_err.is_builder(),
1206            "reqwest invariant: malformed-URL build is a builder error"
1207        );
1208
1209        let http = HttpError(req_err);
1210        assert_eq!(
1211            http.kind(),
1212            HttpErrorKind::Builder,
1213            "HttpError::kind must classify a builder-side error as Builder"
1214        );
1215    }
1216}