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.2).
688    /// Retriability depends on [`JmapError::error_type`](jmap_types::JmapError::error_type)
689    /// (e.g. `serverFail` may be retried; `invalidArguments` is not).
690    ///
691    /// The payload keeps every property [`jmap_types::JmapError`] models
692    /// (`type`, `description`, `limit`), so for example a
693    /// `tooManyChanges` error's `limit` reaches the caller.
694    /// [`method_error_details`](crate::method_error_details) returns any
695    /// other properties. An error object whose `type` is missing, or whose
696    /// `type`, `description` or `limit` is not of its RFC 8620
697    /// type (for example a string or negative `limit`), is
698    /// [`ClientError::Parse`]: RFC 8620 §3.6.2 says it "MUST have a type
699    /// property".
700    #[error("JMAP method error: {0}")]
701    MethodError(jmap_types::JmapError),
702
703    /// A caller-supplied value could not be serialized to JSON. Indicates
704    /// a caller bug — the data structure contains values JSON cannot
705    /// represent. Not retriable.
706    ///
707    /// Returned by [`to_args`](crate::to_args) (and so by every extension
708    /// client method that serializes a caller-supplied argument) and by
709    /// [`WsSession::send_request`](crate::WsSession::send_request). The
710    /// HTTP `call()` path delegates request serialization to reqwest,
711    /// which surfaces serialization failures as [`ClientError::Http`].
712    ///
713    /// The payload is an opaque [`SerializeError`] that does not expose the
714    /// underlying JSON serializer's error type. Construct explicitly:
715    /// `.map_err(ClientError::from_serialize)`. Pattern-match via
716    /// `ClientError::Serialize(e) => e.line()`, `e.column()`, `e.classify()`
717    /// — see [`SerializeError`].
718    #[error("serialization error: {0}")]
719    Serialize(SerializeError),
720
721    /// An SSE frame exceeded the configured buffer limit
722    /// ([`ClientConfig::max_sse_frame`](crate::ClientConfig::max_sse_frame)). The stream is terminated after this
723    /// error. Indicates a misbehaving or hostile server.
724    #[error("SSE frame too large (limit: {limit} bytes)")]
725    SseFrameTooLarge {
726        /// The configured per-frame buffer cap (in bytes) that was exceeded.
727        limit: usize,
728    },
729
730    /// A server response body exceeded the enforced size limit. Protects
731    /// against unbounded memory allocation from malicious or buggy servers.
732    /// `actual` is in bytes (from Content-Length or actual read size).
733    #[error("response too large: {actual} bytes exceeds limit of {limit} bytes")]
734    ResponseTooLarge {
735        /// Observed response size in bytes (from `Content-Length` or the
736        /// running total of bytes read so far when streaming).
737        actual: u64,
738        /// Configured maximum response size in bytes.
739        limit: u64,
740    },
741
742    /// A WebSocket transport error (connection, framing, or TLS). May be
743    /// retriable (transient network failure) or permanent (TLS config error).
744    ///
745    /// The payload is an opaque [`WebSocketError`] that does not expose any
746    /// third-party error type — see [`HttpError`] for the same SemVer
747    /// rationale. Use [`WebSocketError::is_io`],
748    /// [`WebSocketError::is_protocol`], etc. to diagnose.
749    #[error("WebSocket error: {0}")]
750    WebSocket(WebSocketError),
751
752    /// The server closed the WebSocket with a Close frame whose status code
753    /// asks the client to act, rather than ending the stream cleanly.
754    ///
755    /// Produced by [`WsSession::next_frame`](crate::WsSession::next_frame)
756    /// for status code 1008 (Policy Violation, RFC 6455 §7.4.1). A JMAP Chat
757    /// server closes with 1008 when the connection's credentials expire
758    /// (draft-atwood-jmap-chat-wss-00 §Authentication and Identity), so the
759    /// caller should refresh credentials before reconnecting. Any other
760    /// Close frame still ends the stream with `None`. The connection is
761    /// closed: do not call `next_frame` again.
762    #[error("WebSocket closed by server: code {code}: {reason}")]
763    WsClosed {
764        /// The Close frame's status code (RFC 6455 §7.4), e.g. `1008`.
765        code: u16,
766        /// The Close frame's reason text; empty when the server sent none.
767        reason: String,
768    },
769
770    /// The server returned a response that violates the JMAP protocol (outside
771    /// the Session fetch path). Examples: wrong `Content-Type` on an SSE
772    /// connection, unexpected response shape on a non-session endpoint.
773    ///
774    /// Distinct from [`ClientError::InvalidSession`], which indicates a
775    /// problem with the Session document itself. Not retriable without a
776    /// server fix.
777    #[error("unexpected server response: {0}")]
778    UnexpectedResponse(String),
779
780    /// Server rate-limited the request. `retry_after` indicates when to retry.
781    ///
782    /// Produced for an HTTP 429 (Too Many Requests) response from
783    /// [`JmapClient::fetch_session`](crate::JmapClient::fetch_session),
784    /// [`JmapClient::call`](crate::JmapClient::call),
785    /// [`JmapClient::upload_blob`](crate::JmapClient::upload_blob) and
786    /// [`JmapClient::download_blob`](crate::JmapClient::download_blob)
787    /// (and the `_session` wrappers that delegate to them). Extension crates
788    /// also produce it, e.g. for a `rateLimit` SetError. A 429 on the
789    /// event-source connection still surfaces as [`ClientError::Http`] so
790    /// [`JmapClient::subscribe_events_reconnecting`](crate::JmapClient::subscribe_events_reconnecting)
791    /// can keep retrying it.
792    ///
793    /// `retry_after` comes from the `Retry-After` header (RFC 9110 §10.2.3):
794    /// a delay-seconds value is added to the time the response was received,
795    /// and an HTTP-date in any of the three RFC 9110 §5.6.7 formats is used
796    /// as given. When the header is absent or unparseable, `retry_after` is
797    /// the time the response was received — the server gave no delay, so the
798    /// caller should apply its own backoff. Instants past
799    /// `9999-12-31T23:59:59Z` are clamped to it.
800    #[error("rate limited; retry after {retry_after}")]
801    RateLimited {
802        /// Absolute UTC instant the client should wait until before retrying,
803        /// parsed from the `Retry-After` HTTP header (RFC 9110 §10.2.3).
804        retry_after: jmap_types::UTCDate,
805    },
806}
807
808impl ClientError {
809    /// Convert a [`reqwest::Error`] into a [`ClientError::Http`] variant.
810    ///
811    /// `pub(crate)` so downstream callers cannot construct transport-error
812    /// variants — that responsibility belongs to this crate's transport
813    /// layer alone. This is the only conversion path from the third-party
814    /// type into `ClientError`, and is the reason this crate's public API
815    /// no longer mentions `reqwest::Error`.
816    pub(crate) fn from_reqwest(e: reqwest::Error) -> Self {
817        Self::Http(HttpError(e))
818    }
819
820    /// Convert a [`tokio_tungstenite::tungstenite::Error`] into a
821    /// [`ClientError::WebSocket`] variant. See
822    /// [`from_reqwest`](Self::from_reqwest) for the SemVer rationale.
823    pub(crate) fn from_ws(e: tokio_tungstenite::tungstenite::Error) -> Self {
824        Self::WebSocket(WebSocketError(e))
825    }
826
827    /// Convert a [`reqwest::header::InvalidHeaderValue`] into a
828    /// [`ClientError::InvalidHeaderValue`] variant. The inner third-party
829    /// type carries no actionable diagnostic state, so we keep only the
830    /// `Display` representation as a `String`.
831    pub(crate) fn from_invalid_header(e: reqwest::header::InvalidHeaderValue) -> Self {
832        Self::InvalidHeaderValue(InvalidHeaderValueError {
833            message: e.to_string(),
834        })
835    }
836
837    /// Convert a [`serde_json::Error`] from a deserialize / parse step
838    /// into a [`ClientError::Parse`] variant carrying an opaque
839    /// [`ParseError`] payload (bd:JMAP-6r7c.26).
840    ///
841    /// Public because extension client crates (`jmap-mail-client`,
842    /// `jmap-chat-client`, etc.) need to surface their own JSON parse
843    /// failures as [`ClientError::Parse`]. Use in `.map_err`:
844    ///
845    /// ```rust,ignore
846    /// let val: T = serde_json::from_slice(&bytes)
847    ///     .map_err(jmap_base_client::ClientError::from_parse)?;
848    /// ```
849    ///
850    /// A future JSON-parser swap would deprecate this constructor in
851    /// favor of an analogous one for the new parser; the variant
852    /// payload type ([`ParseError`]) stays stable.
853    pub fn from_parse(e: serde_json::Error) -> Self {
854        Self::Parse(ParseError(e))
855    }
856
857    /// Convert a [`serde_json::Error`] from a serialize step into a
858    /// [`ClientError::Serialize`] variant carrying an opaque
859    /// [`SerializeError`] payload (bd:JMAP-6r7c.26).
860    ///
861    /// Public for the same reason as [`from_parse`](Self::from_parse).
862    pub fn from_serialize(e: serde_json::Error) -> Self {
863        Self::Serialize(SerializeError(e))
864    }
865
866    /// Build a [`ClientError::RateLimited`] for an HTTP 429 response
867    /// received at `now`, from its `Retry-After` value (RFC 9110 §10.2.3).
868    /// See the variant docs for the absent / unparseable fallback.
869    pub(crate) fn rate_limited(retry_after: Option<&str>, now: SystemTime) -> Self {
870        let now_secs = now.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
871        let at = retry_after.map(str::trim).and_then(|v| {
872            // delay-seconds = 1*DIGIT; `u64::from_str` alone would also accept "+120".
873            if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
874                // A delay too long for u64 is still "not before year 9999".
875                Some(
876                    v.parse::<u64>()
877                        .map_or(u64::MAX, |d| now_secs.saturating_add(d)),
878                )
879            } else {
880                let t = httpdate::parse_http_date(v).ok()?;
881                t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
882            }
883        });
884        Self::RateLimited {
885            retry_after: utcdate_from_epoch(at.unwrap_or(now_secs)),
886        }
887    }
888}
889
890/// Serialize a method argument to a [`serde_json::Value`] for a JMAP
891/// method call's `arguments` object.
892///
893/// Every extension client uses this to put a caller-supplied value
894/// (filter, sort, patch map, id list, params struct) into a request, so
895/// a value that cannot be represented as JSON is an error, never a
896/// panic. Filter and comparator types are often caller-supplied
897/// generics whose `Serialize` impl this crate cannot see.
898///
899/// # Errors
900///
901/// [`ClientError::Serialize`] when `value`'s `Serialize` impl fails, for
902/// example a map whose keys do not serialize as strings (JSON object
903/// keys are strings, RFC 8259 §4).
904pub fn to_args<T: serde::Serialize + ?Sized>(value: &T) -> Result<serde_json::Value, ClientError> {
905    serde_json::to_value(value).map_err(ClientError::from_serialize)
906}
907
908/// Format seconds since the Unix epoch as an RFC 8620 §1.4 `UTCDate`,
909/// clamped to `9999-12-31T23:59:59Z` (the largest four-digit year).
910///
911/// Uses Howard Hinnant's `civil_from_days` ("chrono-Compatible Low-Level
912/// Date Algorithms"), the inverse of the `days_from_civil` that
913/// [`jmap_types::UTCDate::to_epoch_seconds`] uses.
914fn utcdate_from_epoch(secs: u64) -> jmap_types::UTCDate {
915    let secs = secs.min(253_402_300_799);
916    let (days, rem) = (secs / 86_400, secs % 86_400);
917    let z = days + 719_468;
918    let era = z / 146_097;
919    let doe = z - era * 146_097; // [0, 146096]
920    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
921    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
922    let mp = (5 * doy + 2) / 153; // [0, 11], March-based month
923    let day = doy - (153 * mp + 2) / 5 + 1;
924    let month = if mp < 10 { mp + 3 } else { mp - 9 };
925    let year = yoe + era * 400 + u64::from(month <= 2);
926    format!(
927        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
928        rem / 3600,
929        rem / 60 % 60,
930        rem % 60
931    )
932    .into()
933}
934
935// ---------------------------------------------------------------------------
936// Tests
937// ---------------------------------------------------------------------------
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    /// Verify ClientError variants by exhaustive match. Variant names are
944    /// part of the public API; this catches accidental rename / removal.
945    #[test]
946    fn client_error_exhaustive_match() {
947        let e = ClientError::InvalidArgument("test".into());
948        match e {
949            ClientError::Http(_) => {}
950            ClientError::InvalidHeaderValue(_) => {}
951            ClientError::AuthFailed(_) => {}
952            ClientError::Parse(_) => {}
953            ClientError::BlobIntegrityMismatch { .. } => {}
954            ClientError::InvalidArgument(_) => {}
955            ClientError::InvalidSession(_) => {}
956            ClientError::MethodNotFound(_) => {}
957            ClientError::MethodError(_) => {}
958            ClientError::Serialize(_) => {}
959            ClientError::SseFrameTooLarge { .. } => {}
960            ClientError::ResponseTooLarge { .. } => {}
961            ClientError::WebSocket(_) => {}
962            ClientError::WsClosed { .. } => {}
963            ClientError::UnexpectedResponse(_) => {}
964            ClientError::RateLimited { .. } => {}
965        }
966    }
967
968    /// InvalidHeaderValueError preserves the underlying message so the
969    /// Display output matches the third-party error's Display verbatim —
970    /// callers that previously logged the `ClientError::InvalidHeaderValue`
971    /// variant see the same diagnostic text after the wrapper rename.
972    ///
973    /// Independent oracle: reqwest::header::InvalidHeaderValue is produced
974    /// by HeaderValue::from_str on bytes that are not valid header values
975    /// (e.g. embedded newline). The wrapper's Display is just the inner
976    /// type's Display.
977    #[test]
978    fn invalid_header_value_preserves_message() {
979        let inner_err = reqwest::header::HeaderValue::from_str("bad\nvalue")
980            .expect_err("newline must be rejected as a header value");
981        let inner_display = inner_err.to_string();
982
983        let ce = ClientError::from_invalid_header(inner_err);
984        let ClientError::InvalidHeaderValue(ihve) = &ce else {
985            panic!("must be InvalidHeaderValue variant, got {ce:?}");
986        };
987        assert_eq!(
988            ihve.message(),
989            inner_display,
990            "wrapper message must equal inner Display"
991        );
992        // Outer ClientError Display includes the prefix.
993        assert!(
994            ce.to_string().starts_with("invalid header value: "),
995            "ClientError Display must use the variant's #[error] prefix: {ce}"
996        );
997    }
998
999    /// An HttpError constructed from a reqwest builder failure exposes the
1000    /// expected diagnostic accessor values: is_builder=true, status=None,
1001    /// and a non-empty Display. Independent oracle: reqwest's documented
1002    /// behaviour for invalid URLs (builder error, no status code).
1003    #[test]
1004    fn http_error_from_invalid_url_is_builder_error() {
1005        // reqwest::Client::new().get("not a url") produces a builder error
1006        // when the URL fails to parse. Building the request and calling
1007        // .send() requires async context; .build() is synchronous and
1008        // suffices to provoke a parse failure.
1009        let client = reqwest::Client::new();
1010        let build_err = client
1011            .request(reqwest::Method::GET, "://not-a-url")
1012            .build()
1013            .expect_err("malformed URL must produce a build error");
1014
1015        let ce = ClientError::from_reqwest(build_err);
1016        let ClientError::Http(http_err) = &ce else {
1017            panic!("must be Http variant, got {ce:?}");
1018        };
1019        assert!(
1020            http_err.is_builder(),
1021            "malformed URL must be classified as a builder error"
1022        );
1023        assert!(
1024            http_err.status().is_none(),
1025            "builder errors carry no HTTP status"
1026        );
1027        assert!(
1028            !http_err.is_timeout(),
1029            "builder error must not classify as timeout"
1030        );
1031        assert!(
1032            !http_err.is_connect(),
1033            "builder error must not classify as connect"
1034        );
1035        assert!(
1036            !http_err.to_string().is_empty(),
1037            "Display must produce a non-empty diagnostic"
1038        );
1039    }
1040
1041    /// A WebSocketError wrapping ConnectionClosed correctly classifies via
1042    /// its accessor methods. Independent oracle: tungstenite's documented
1043    /// Error variants are matched directly via the matches! macro.
1044    #[test]
1045    fn websocket_error_classifies_connection_closed() {
1046        let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
1047        let ce = ClientError::from_ws(inner);
1048        let ClientError::WebSocket(ws_err) = &ce else {
1049            panic!("must be WebSocket variant, got {ce:?}");
1050        };
1051        assert!(ws_err.is_connection_closed());
1052        assert!(!ws_err.is_already_closed());
1053        assert!(!ws_err.is_io());
1054        assert!(!ws_err.is_protocol());
1055        assert!(!ws_err.is_capacity());
1056    }
1057
1058    /// A WebSocketError wrapping an Io variant correctly classifies via
1059    /// is_io. Independent oracle: tungstenite::Error::Io is the documented
1060    /// wrapper for std::io::Error sources.
1061    #[test]
1062    fn websocket_error_classifies_io() {
1063        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test");
1064        let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
1065        let ce = ClientError::from_ws(inner);
1066        let ClientError::WebSocket(ws_err) = &ce else {
1067            panic!("must be WebSocket variant, got {ce:?}");
1068        };
1069        assert!(ws_err.is_io());
1070        assert!(!ws_err.is_connection_closed());
1071        assert!(!ws_err.is_already_closed());
1072    }
1073
1074    /// HttpError, WebSocketError, and InvalidHeaderValueError all implement
1075    /// std::error::Error. This is a regression guard against any future
1076    /// refactor that drops one of these impls (which would silently break
1077    /// downstream code that iterates the source chain via Error::source).
1078    #[test]
1079    fn wrapper_types_implement_std_error() {
1080        fn assert_error<E: StdError>() {}
1081        assert_error::<HttpError>();
1082        assert_error::<WebSocketError>();
1083        assert_error::<InvalidHeaderValueError>();
1084        assert_error::<ParseError>();
1085        assert_error::<SerializeError>();
1086    }
1087
1088    // bd:JMAP-6r7c.26 — wrapping serde_json::Error behind ParseError and
1089    // SerializeError. Independent oracles below: hand-crafted JSON inputs
1090    // whose serde_json::Error categorisation is well-documented.
1091
1092    /// `ClientError::from_parse` wraps a syntax-error JSON failure into a
1093    /// `ClientError::Parse` carrying a `ParseError` whose `classify()` reports
1094    /// `ParseCategory::Syntax`. Oracle: `{` alone is documented as a syntax
1095    /// failure by serde_json (premature `{` with no member).
1096    #[test]
1097    fn parse_error_classifies_syntax_failure() {
1098        let inner =
1099            serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
1100        let ce = ClientError::from_parse(inner);
1101        let ClientError::Parse(pe) = &ce else {
1102            panic!("must be Parse variant, got {ce:?}");
1103        };
1104        // Serde-json reports EOF for an unclosed object on a stream that
1105        // contains nothing after `{` — the input was prematurely
1106        // terminated. Either Syntax or Eof is acceptable here; both are
1107        // structural-parse failures. Lock both as valid outcomes so a
1108        // future serde_json upgrade that flips classification does not
1109        // wrongly fail this test.
1110        assert!(
1111            matches!(pe.classify(), ParseCategory::Syntax | ParseCategory::Eof),
1112            "expected Syntax or Eof, got {:?}",
1113            pe.classify()
1114        );
1115        // line/column are 1-based and non-zero for any parse failure
1116        // with a position; the unclosed `{` has a position.
1117        assert!(pe.line() > 0, "line must be 1-based and non-zero");
1118    }
1119
1120    /// `ClientError::from_parse` on a type-mismatch error reports
1121    /// `ParseCategory::Data`. Oracle: feeding `"not a number"` to
1122    /// `serde_json::from_str::<u32>` is documented as a Data error
1123    /// (semantic mismatch, not syntax).
1124    #[test]
1125    fn parse_error_classifies_data_failure() {
1126        let inner = serde_json::from_str::<u32>("\"not a number\"")
1127            .expect_err("string must fail to deserialise as u32");
1128        let ce = ClientError::from_parse(inner);
1129        let ClientError::Parse(pe) = &ce else {
1130            panic!("must be Parse variant, got {ce:?}");
1131        };
1132        assert_eq!(pe.classify(), ParseCategory::Data);
1133    }
1134
1135    /// `ClientError::from_serialize` wraps a `serde_json::Error` from a
1136    /// serialise step into a `ClientError::Serialize` carrying an opaque
1137    /// `SerializeError`. Oracle: a `BTreeMap` whose keys are a tuple
1138    /// type cannot serialise as JSON because JSON object keys MUST be
1139    /// strings — serde_json documents this as `Error::key_must_be_a_string`.
1140    #[test]
1141    fn serialize_error_wraps_non_string_map_key() {
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, got {ce:?}");
1150        };
1151        // Display surface is non-empty (the underlying serde_json::Error
1152        // produces a diagnostic).
1153        assert!(
1154            !se.to_string().is_empty(),
1155            "SerializeError Display must be non-empty"
1156        );
1157    }
1158
1159    /// `ParseError`'s `Debug` output formats as `ParseError(...)` with the
1160    /// inner serde_json::Error nested, NOT as a bare serde_json::Error.
1161    /// This locks in the wrapper-rename for any code that pattern-matches
1162    /// on Debug output (e.g. a snapshot test) without exposing the inner
1163    /// type as the top-level shape.
1164    #[test]
1165    fn parse_error_debug_format_uses_wrapper_name() {
1166        let inner =
1167            serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
1168        let ce = ClientError::from_parse(inner);
1169        let ClientError::Parse(pe) = &ce else {
1170            panic!("must be Parse variant");
1171        };
1172        let debug = format!("{pe:?}");
1173        assert!(
1174            debug.starts_with("ParseError("),
1175            "Debug must use the wrapper tuple-struct name: {debug}"
1176        );
1177    }
1178
1179    /// `to_args` maps a serialize failure to `ClientError::Serialize`.
1180    /// Oracle: JSON object keys MUST be strings (RFC 8259 §4), so a
1181    /// tuple-keyed map has no JSON form; serde_json documents this as
1182    /// `Error::key_must_be_a_string`.
1183    #[test]
1184    fn to_args_maps_failure_to_serialize_variant() {
1185        let mut map: std::collections::BTreeMap<(i32, i32), &str> =
1186            std::collections::BTreeMap::new();
1187        map.insert((1, 2), "value");
1188        let err = to_args(&map).expect_err("tuple-keyed map has no JSON form");
1189        assert!(
1190            matches!(err, ClientError::Serialize(_)),
1191            "must be Serialize variant, got {err:?}"
1192        );
1193    }
1194
1195    /// `to_args` returns the JSON form of a serializable value. Oracle:
1196    /// the hand-written JSON a JMAP `ids` argument carries.
1197    #[test]
1198    fn to_args_returns_json_value() {
1199        let v = to_args(&["a1", "b2"][..]).expect("string slice serializes");
1200        assert_eq!(v, serde_json::json!(["a1", "b2"]));
1201    }
1202
1203    /// `SerializeError` Debug format mirror.
1204    #[test]
1205    fn serialize_error_debug_format_uses_wrapper_name() {
1206        let mut map: std::collections::BTreeMap<(i32, i32), &str> =
1207            std::collections::BTreeMap::new();
1208        map.insert((1, 2), "value");
1209        let inner = serde_json::to_string(&map)
1210            .expect_err("tuple-keyed map must not serialise as a JSON object");
1211        let ce = ClientError::from_serialize(inner);
1212        let ClientError::Serialize(se) = &ce else {
1213            panic!("must be Serialize variant");
1214        };
1215        let debug = format!("{se:?}");
1216        assert!(
1217            debug.starts_with("SerializeError("),
1218            "Debug must use the wrapper tuple-struct name: {debug}"
1219        );
1220    }
1221
1222    /// bd:JMAP-6r7c.34 — verify every HttpErrorKind variant by exhaustive
1223    /// match. A future variant addition (forgetting to update this match)
1224    /// fails the test, forcing a deliberate choice rather than silent
1225    /// API drift.
1226    #[test]
1227    fn http_error_kind_exhaustive_match() {
1228        let k = HttpErrorKind::Other;
1229        match k {
1230            HttpErrorKind::Timeout => {}
1231            HttpErrorKind::Connect => {}
1232            HttpErrorKind::Redirect => {}
1233            HttpErrorKind::Status(_) => {}
1234            HttpErrorKind::RequestBody => {}
1235            HttpErrorKind::ResponseBody => {}
1236            HttpErrorKind::Decode => {}
1237            HttpErrorKind::Builder => {}
1238            HttpErrorKind::Other => {}
1239        }
1240    }
1241
1242    /// bd:JMAP-6r7c.34 — exhaustive match over WebSocketErrorKind. Same
1243    /// regression-tripwire role as http_error_kind_exhaustive_match.
1244    #[test]
1245    fn ws_error_kind_exhaustive_match() {
1246        let k = WebSocketErrorKind::Other;
1247        match k {
1248            WebSocketErrorKind::ConnectionClosed => {}
1249            WebSocketErrorKind::AlreadyClosed => {}
1250            WebSocketErrorKind::Url => {}
1251            WebSocketErrorKind::Protocol => {}
1252            WebSocketErrorKind::Capacity => {}
1253            WebSocketErrorKind::Io => {}
1254            WebSocketErrorKind::Other => {}
1255        }
1256    }
1257
1258    /// bd:JMAP-6r7c.34 — independent oracle for the ConnectionClosed
1259    /// classification. tungstenite::Error::ConnectionClosed is a unit
1260    /// variant constructible directly; the test wraps it through
1261    /// WebSocketError::from and asserts kind() == ConnectionClosed.
1262    #[test]
1263    fn ws_error_kind_classifies_connection_closed() {
1264        let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
1265        let ws = WebSocketError(inner);
1266        assert_eq!(ws.kind(), WebSocketErrorKind::ConnectionClosed);
1267    }
1268
1269    /// bd:JMAP-6r7c.34 — independent oracle for the Io classification.
1270    /// std::io::Error is constructible directly and tungstenite::Error
1271    /// has a `From<std::io::Error>` impl that produces the Io variant.
1272    #[test]
1273    fn ws_error_kind_classifies_io() {
1274        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test");
1275        let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
1276        let ws = WebSocketError(inner);
1277        assert_eq!(ws.kind(), WebSocketErrorKind::Io);
1278    }
1279
1280    /// bd:JMAP-6r7c.34 — independent oracle for the Builder classification.
1281    /// reqwest::Client::get on a malformed URL produces a builder-time
1282    /// error; the test sends through HttpError::from(reqwest::Error) and
1283    /// asserts kind() == Builder. The test does NOT use HttpError's own
1284    /// is_builder() as the oracle — it uses reqwest's invariant that
1285    /// "an unparseable URL produces a builder error" as the independent
1286    /// claim, and asserts the wrapper preserves the classification.
1287    #[test]
1288    fn http_error_kind_classifies_builder_error() {
1289        // reqwest::ClientBuilder::new().build() then .get on a malformed
1290        // URL is the canonical "builder error" production path. The
1291        // empty string is rejected as not-a-URL.
1292        let client = reqwest::ClientBuilder::new().build().expect("build");
1293        let req_err = client
1294            .request(reqwest::Method::GET, "not a url")
1295            .build()
1296            .expect_err("malformed URL must produce a request builder error");
1297        // Independent oracle: reqwest documents that URL parse failures
1298        // during build come back as is_builder() == true.
1299        assert!(
1300            req_err.is_builder(),
1301            "reqwest invariant: malformed-URL build is a builder error"
1302        );
1303
1304        let http = HttpError(req_err);
1305        assert_eq!(
1306            http.kind(),
1307            HttpErrorKind::Builder,
1308            "HttpError::kind must classify a builder-side error as Builder"
1309        );
1310    }
1311
1312    /// `now` for the Retry-After tests: 2014-10-30T06:12:00Z, the RFC 8620
1313    /// §1.4 example instant (epoch value from `date -u -d @1414649520`).
1314    fn rfc8620_now() -> SystemTime {
1315        UNIX_EPOCH + std::time::Duration::from_secs(1_414_649_520)
1316    }
1317
1318    fn retry_after_of(e: ClientError) -> String {
1319        match e {
1320            ClientError::RateLimited { retry_after } => retry_after.into_inner(),
1321            other => panic!("expected RateLimited, got {other:?}"),
1322        }
1323    }
1324
1325    /// RFC 9110 §5.6.7: "A recipient that parses a timestamp value in an
1326    /// HTTP field MUST accept all three HTTP-date formats." The section's
1327    /// examples are "Sun, 06 Nov 1994 08:49:37 GMT" (IMF-fixdate),
1328    /// "Sunday, 06-Nov-94 08:49:37 GMT" (RFC 850) and
1329    /// "Sun Nov  6 08:49:37 1994" (asctime), all the same instant.
1330    #[test]
1331    fn rate_limited_accepts_all_three_http_date_formats() {
1332        for v in [
1333            "Sun, 06 Nov 1994 08:49:37 GMT",
1334            "Sunday, 06-Nov-94 08:49:37 GMT",
1335            "Sun Nov  6 08:49:37 1994",
1336        ] {
1337            assert_eq!(
1338                retry_after_of(ClientError::rate_limited(Some(v), rfc8620_now())),
1339                "1994-11-06T08:49:37Z",
1340                "Retry-After: {v}"
1341            );
1342        }
1343    }
1344
1345    /// RFC 9110 §10.2.3 examples: "Retry-After: Fri, 31 Dec 1999 23:59:59
1346    /// GMT" and "Retry-After: 120 ... In the latter example, the delay is
1347    /// 2 minutes."
1348    #[test]
1349    fn rate_limited_parses_rfc9110_retry_after_examples() {
1350        assert_eq!(
1351            retry_after_of(ClientError::rate_limited(
1352                Some("Fri, 31 Dec 1999 23:59:59 GMT"),
1353                rfc8620_now()
1354            )),
1355            "1999-12-31T23:59:59Z"
1356        );
1357        assert_eq!(
1358            retry_after_of(ClientError::rate_limited(Some("120"), rfc8620_now())),
1359            "2014-10-30T06:14:00Z"
1360        );
1361    }
1362
1363    /// RFC 9110 §10.2.3: `delay-seconds = 1*DIGIT`, so "+120" and "" are
1364    /// neither delay-seconds nor HTTP-date; with no usable value the
1365    /// documented fallback is the receive time.
1366    #[test]
1367    fn rate_limited_falls_back_to_now_without_usable_value() {
1368        for v in [None, Some(""), Some("+120"), Some("soon")] {
1369            assert_eq!(
1370                retry_after_of(ClientError::rate_limited(v, rfc8620_now())),
1371                "2014-10-30T06:12:00Z",
1372                "Retry-After: {v:?}"
1373            );
1374        }
1375    }
1376
1377    /// RFC 8620 §1.4 UTCDate has a four-digit year; a delay-seconds value
1378    /// overflowing u64 clamps to 9999-12-31T23:59:59Z
1379    /// (`date -u -d @253402300799`).
1380    #[test]
1381    fn rate_limited_clamps_huge_delay() {
1382        assert_eq!(
1383            retry_after_of(ClientError::rate_limited(
1384                Some("99999999999999999999999"),
1385                rfc8620_now()
1386            )),
1387            "9999-12-31T23:59:59Z"
1388        );
1389    }
1390
1391    /// Cross-validation against the independent `days_from_civil`
1392    /// implementation in `jmap_types::UTCDate::to_epoch_seconds`, plus
1393    /// fixed vectors from GNU `date -u -d @N`: 951825600 is
1394    /// 2000-02-29T12:00:00Z (a 400-year leap day) and 784111777 is
1395    /// 1994-11-06T08:49:37Z.
1396    #[test]
1397    fn utcdate_from_epoch_matches_independent_inverse() {
1398        assert_eq!(utcdate_from_epoch(951_825_600), "2000-02-29T12:00:00Z");
1399        assert_eq!(utcdate_from_epoch(784_111_777), "1994-11-06T08:49:37Z");
1400        // Step by a prime so every time-of-day and calendar position is hit.
1401        let mut s = 0u64;
1402        while s <= 253_402_300_799 {
1403            let d = utcdate_from_epoch(s);
1404            let parsed = jmap_types::UTCDate::new_validated(d.as_ref())
1405                .unwrap_or_else(|e| panic!("{d} invalid: {e}"));
1406            assert_eq!(parsed.to_epoch_seconds().unwrap(), s as i64, "{d}");
1407            s += 9_999_991;
1408        }
1409    }
1410}