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