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