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}