jmap_base_client/error.rs
1//! [`ClientError`] and the opaque wrapper types ([`HttpError`],
2//! [`WebSocketError`], [`InvalidHeaderValueError`]) that hide
3//! [`reqwest`] and [`tokio_tungstenite`] from this crate's public API.
4//!
5//! # SemVer policy
6//!
7//! `reqwest` and `tokio-tungstenite` are **private dependencies** of this
8//! crate. Their types do not appear in any public function signature,
9//! variant payload, or `From` impl. The wrapper types ([`HttpError`],
10//! [`WebSocketError`], [`InvalidHeaderValueError`]) expose a curated set of
11//! diagnostic accessors that return primitive types only, so this crate can
12//! bump the underlying transport's major version without breaking
13//! downstream callers.
14//!
15//! Internal construction goes through `pub(crate)` helpers on
16//! [`ClientError`] (`from_reqwest`, `from_ws`, `from_invalid_header`) —
17//! downstream consumers cannot construct the transport-error variants and
18//! never need to.
19
20use std::error::Error as StdError;
21use std::fmt;
22
23// ---------------------------------------------------------------------------
24// HttpError — opaque wrapper around reqwest::Error
25// ---------------------------------------------------------------------------
26
27/// HTTP transport error reported by the underlying HTTP client.
28///
29/// The inner third-party error type is private; callers diagnose the failure
30/// via the accessor methods, all of which return primitive types so this
31/// crate can swap or bump the underlying HTTP client without breaking the
32/// public API.
33#[non_exhaustive]
34pub struct HttpError(reqwest::Error);
35
36impl HttpError {
37 /// `true` if the request timed out before a response was received.
38 pub fn is_timeout(&self) -> bool {
39 self.0.is_timeout()
40 }
41 /// `true` if the underlying connection could not be established
42 /// (DNS failure, TCP refused, TLS handshake failure, etc.).
43 pub fn is_connect(&self) -> bool {
44 self.0.is_connect()
45 }
46 /// `true` if the error originated in the request builder
47 /// (URL parse failure, invalid header construction at build time, etc.).
48 pub fn is_builder(&self) -> bool {
49 self.0.is_builder()
50 }
51 /// `true` if the error is a redirect-loop or too-many-redirects failure.
52 pub fn is_redirect(&self) -> bool {
53 self.0.is_redirect()
54 }
55 /// `true` if the error originated from a non-success HTTP status.
56 pub fn is_status(&self) -> bool {
57 self.0.is_status()
58 }
59 /// `true` if the error happened while sending the request body.
60 pub fn is_request(&self) -> bool {
61 self.0.is_request()
62 }
63 /// `true` if the error happened while receiving / decoding the response body.
64 pub fn is_body(&self) -> bool {
65 self.0.is_body()
66 }
67 /// `true` if the response body could not be decoded as the requested
68 /// representation (e.g. JSON parse failure inside the transport layer).
69 pub fn is_decode(&self) -> bool {
70 self.0.is_decode()
71 }
72 /// HTTP status code if the error came from a non-success response;
73 /// `None` for transport-level failures (timeout, connection refused, etc.).
74 pub fn status(&self) -> Option<u16> {
75 self.0.status().map(|s| s.as_u16())
76 }
77 /// URL the request was sent to, if known. Returned as an owned `String`
78 /// to avoid leaking the underlying transport's `Url` type into this
79 /// crate's public API.
80 pub fn url(&self) -> Option<String> {
81 self.0.url().map(ToString::to_string)
82 }
83}
84
85impl fmt::Display for HttpError {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 fmt::Display::fmt(&self.0, f)
88 }
89}
90
91impl fmt::Debug for HttpError {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.debug_tuple("HttpError").field(&self.0).finish()
94 }
95}
96
97impl StdError for HttpError {
98 fn source(&self) -> Option<&(dyn StdError + 'static)> {
99 Some(&self.0)
100 }
101}
102
103// ---------------------------------------------------------------------------
104// WebSocketError — opaque wrapper around tokio_tungstenite::tungstenite::Error
105// ---------------------------------------------------------------------------
106
107/// WebSocket transport error reported by the underlying WebSocket client.
108///
109/// As with [`HttpError`], the inner third-party type is private and
110/// diagnostics are exposed via accessor methods returning primitive types.
111#[non_exhaustive]
112pub struct WebSocketError(tokio_tungstenite::tungstenite::Error);
113
114impl WebSocketError {
115 /// `true` if the peer cleanly closed the connection.
116 pub fn is_connection_closed(&self) -> bool {
117 matches!(
118 &self.0,
119 tokio_tungstenite::tungstenite::Error::ConnectionClosed
120 )
121 }
122 /// `true` if the connection was already closed when the operation was
123 /// attempted (caller bug or race).
124 pub fn is_already_closed(&self) -> bool {
125 matches!(
126 &self.0,
127 tokio_tungstenite::tungstenite::Error::AlreadyClosed
128 )
129 }
130 /// `true` if the error wraps an underlying `std::io::Error`.
131 pub fn is_io(&self) -> bool {
132 matches!(&self.0, tokio_tungstenite::tungstenite::Error::Io(_))
133 }
134 /// `true` if the error is a WebSocket protocol violation
135 /// (malformed frame, invalid opcode, etc.).
136 pub fn is_protocol(&self) -> bool {
137 matches!(&self.0, tokio_tungstenite::tungstenite::Error::Protocol(_))
138 }
139 /// `true` if a frame or message exceeded a configured size limit.
140 pub fn is_capacity(&self) -> bool {
141 matches!(&self.0, tokio_tungstenite::tungstenite::Error::Capacity(_))
142 }
143 /// `true` if the WebSocket URL was invalid.
144 pub fn is_url(&self) -> bool {
145 matches!(&self.0, tokio_tungstenite::tungstenite::Error::Url(_))
146 }
147}
148
149impl fmt::Display for WebSocketError {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 fmt::Display::fmt(&self.0, f)
152 }
153}
154
155impl fmt::Debug for WebSocketError {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 f.debug_tuple("WebSocketError").field(&self.0).finish()
158 }
159}
160
161impl StdError for WebSocketError {
162 fn source(&self) -> Option<&(dyn StdError + 'static)> {
163 Some(&self.0)
164 }
165}
166
167// ---------------------------------------------------------------------------
168// InvalidHeaderValueError — string-only wrapper, no third-party leak
169// ---------------------------------------------------------------------------
170
171/// A header value (typically an authentication token) contained bytes that
172/// are not valid for an HTTP header.
173///
174/// The inner type is just a string message; there is no actionable
175/// diagnostic state beyond that, so this wrapper does not expose any
176/// accessor beyond [`Display`](fmt::Display).
177#[non_exhaustive]
178pub struct InvalidHeaderValueError {
179 message: String,
180}
181
182impl InvalidHeaderValueError {
183 /// The human-readable description of the failure.
184 pub fn message(&self) -> &str {
185 &self.message
186 }
187}
188
189impl fmt::Display for InvalidHeaderValueError {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 f.write_str(&self.message)
192 }
193}
194
195impl fmt::Debug for InvalidHeaderValueError {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 f.debug_struct("InvalidHeaderValueError")
198 .field("message", &self.message)
199 .finish()
200 }
201}
202
203impl StdError for InvalidHeaderValueError {}
204
205// ---------------------------------------------------------------------------
206// ClientError
207// ---------------------------------------------------------------------------
208
209/// Errors produced by the base JMAP client.
210///
211/// Variants cover transport failures (`Http`, `WebSocket`), authentication
212/// (`AuthFailed`), JMAP protocol errors (`MethodError`, `UnexpectedResponse`),
213/// caller bugs (`InvalidArgument`, `InvalidHeaderValue`, `Serialize`), and
214/// resource-exhaustion guards (`ResponseTooLarge`, `SseFrameTooLarge`).
215///
216/// Marked `#[non_exhaustive]` so additional variants may be introduced in
217/// minor releases. See per-variant documentation for retriability guidance.
218#[non_exhaustive]
219#[derive(Debug, thiserror::Error)]
220pub enum ClientError {
221 /// Network or TLS error from the HTTP layer. May be retriable (transient
222 /// network failure) or permanent (TLS configuration error). Indicates a
223 /// network or transport problem, not a JMAP protocol error.
224 ///
225 /// The payload is an opaque [`HttpError`] that does not expose any
226 /// third-party error type — this crate's HTTP transport can be swapped
227 /// or its major version bumped without affecting downstream callers.
228 /// Use [`HttpError::is_timeout`], [`HttpError::status`], etc. to diagnose.
229 #[error("HTTP error: {0}")]
230 Http(HttpError),
231
232 /// A header value could not be encoded. Indicates a caller bug — the
233 /// credential string contains characters that are not valid HTTP header
234 /// value characters. Not retriable.
235 #[error("invalid header value: {0}")]
236 InvalidHeaderValue(InvalidHeaderValueError),
237
238 /// The server returned HTTP 401 (authentication failure) or 403
239 /// (authorization failure — credentials present but insufficient). Not
240 /// retriable without correcting credentials.
241 #[error("authentication or authorization failure: HTTP {0}")]
242 AuthFailed(u16),
243
244 /// A server response could not be parsed or did not match the expected
245 /// shape. Indicates the server sent a malformed response. Not retriable
246 /// without a server fix.
247 ///
248 /// Construct explicitly: `.map_err(ClientError::Parse)`.
249 #[error("parse error: {0}")]
250 Parse(serde_json::Error),
251
252 /// Downloaded blob SHA-256 does not match the expected digest. Indicates
253 /// in-transit corruption or a misbehaving server. Not retriable without
254 /// re-fetching metadata.
255 #[error("blob integrity check failed: expected {expected}, got {actual}")]
256 BlobIntegrityMismatch {
257 /// Hex-encoded SHA-256 digest the caller asked the client to verify against.
258 expected: String,
259 /// Hex-encoded SHA-256 digest actually computed over the downloaded bytes.
260 actual: String,
261 },
262
263 /// A caller-supplied argument violates a precondition (e.g. empty token,
264 /// colon in BasicAuth username, missing required filter field).
265 #[error("invalid argument: {0}")]
266 InvalidArgument(String),
267
268 /// The JMAP Session object from the server was missing a required field.
269 /// Indicates a server-side bug or incompatible server. Not retriable.
270 #[error("invalid session: {0}")]
271 InvalidSession(String),
272
273 /// The JMAP API response did not contain the expected method call ID.
274 /// Indicates a server-side bug or unexpected response shape.
275 #[error("method not found in response: {0}")]
276 MethodNotFound(String),
277
278 /// The JMAP server returned a method-level error object (RFC 8620 §3.6).
279 /// Retriability depends on `error_type` (e.g. `serverFail` may be
280 /// retried; `invalidArguments` is not retriable).
281 ///
282 /// `description` is `None` when the server omits the optional description field.
283 #[error("JMAP method error: {error_type}")]
284 MethodError {
285 /// The `type` field of the JMAP method-level error object (RFC 8620 §3.6.2),
286 /// e.g. `"invalidArguments"`, `"serverFail"`, `"accountNotFound"`.
287 error_type: String,
288 /// Optional human-readable error description (RFC 8620 §3.6.2);
289 /// `None` when the server omits this field.
290 description: Option<String>,
291 },
292
293 /// A JMAP request could not be serialized to JSON when sending over
294 /// WebSocket. Indicates a caller bug — the data structure contains
295 /// non-serializable values. Not retriable.
296 ///
297 /// This error is only returned by [`WsSession::send_request`](crate::WsSession::send_request); the HTTP
298 /// `call()` path delegates serialization to reqwest, which surfaces
299 /// serialization failures as [`ClientError::Http`].
300 ///
301 /// Construct explicitly: `.map_err(ClientError::Serialize)`.
302 #[error("serialization error: {0}")]
303 Serialize(serde_json::Error),
304
305 /// An SSE frame exceeded the configured buffer limit
306 /// ([`ClientConfig::max_sse_frame`](crate::ClientConfig::max_sse_frame)). The stream is terminated after this
307 /// error. Indicates a misbehaving or hostile server.
308 #[error("SSE frame too large (limit: {limit} bytes)")]
309 SseFrameTooLarge {
310 /// The configured per-frame buffer cap (in bytes) that was exceeded.
311 limit: usize,
312 },
313
314 /// A server response body exceeded the enforced size limit. Protects
315 /// against unbounded memory allocation from malicious or buggy servers.
316 /// `actual` is in bytes (from Content-Length or actual read size).
317 #[error("response too large: {actual} bytes exceeds limit of {limit} bytes")]
318 ResponseTooLarge {
319 /// Observed response size in bytes (from `Content-Length` or the
320 /// running total of bytes read so far when streaming).
321 actual: u64,
322 /// Configured maximum response size in bytes.
323 limit: u64,
324 },
325
326 /// A WebSocket transport error (connection, framing, or TLS). May be
327 /// retriable (transient network failure) or permanent (TLS config error).
328 ///
329 /// The payload is an opaque [`WebSocketError`] that does not expose any
330 /// third-party error type — see [`HttpError`] for the same SemVer
331 /// rationale. Use [`WebSocketError::is_io`],
332 /// [`WebSocketError::is_protocol`], etc. to diagnose.
333 #[error("WebSocket error: {0}")]
334 WebSocket(WebSocketError),
335
336 /// The server returned a response that violates the JMAP protocol (outside
337 /// the Session fetch path). Examples: wrong `Content-Type` on an SSE
338 /// connection, unexpected response shape on a non-session endpoint.
339 ///
340 /// Distinct from [`ClientError::InvalidSession`], which indicates a
341 /// problem with the Session document itself. Not retriable without a
342 /// server fix.
343 #[error("unexpected server response: {0}")]
344 UnexpectedResponse(String),
345
346 /// Server rate-limited the request. `retry_after` indicates when to retry.
347 ///
348 /// **Note (bd:JMAP-6lsm.3): this base crate does not currently produce
349 /// this variant.** HTTP 429 responses fall through reqwest's
350 /// `error_for_status()` and surface as [`ClientError::Http`] instead.
351 /// The variant is part of the public contract so:
352 ///
353 /// 1. Extension crates that wrap or replace this crate's transport may
354 /// detect 429 + parse `Retry-After` themselves and produce
355 /// `RateLimited` from their own error-conversion code.
356 /// 2. Callers that want to handle rate limiting via this typed variant
357 /// have a stable target to match on, even before the conversion
358 /// logic lands here (tracked under `bd:JMAP-6lsm.3`).
359 ///
360 /// If you encounter a 429 today, match on `ClientError::Http` and call
361 /// [`HttpError::status`] to confirm `Some(429)`. The base crate may
362 /// gain native 429 → `RateLimited` conversion in a future minor
363 /// release; the variant shape will not change in a backward-incompatible
364 /// way (it is `#[non_exhaustive]` via the enum-level annotation, so
365 /// extra fields can be added without a SemVer break).
366 #[error("rate limited; retry after {retry_after}")]
367 RateLimited {
368 /// Absolute UTC instant the client should wait until before retrying,
369 /// parsed from the `Retry-After` HTTP header (RFC 9110 §10.2.3).
370 retry_after: jmap_types::UTCDate,
371 },
372}
373
374impl ClientError {
375 /// Convert a [`reqwest::Error`] into a [`ClientError::Http`] variant.
376 ///
377 /// `pub(crate)` so downstream callers cannot construct transport-error
378 /// variants — that responsibility belongs to this crate's transport
379 /// layer alone. This is the only conversion path from the third-party
380 /// type into `ClientError`, and is the reason this crate's public API
381 /// no longer mentions `reqwest::Error`.
382 pub(crate) fn from_reqwest(e: reqwest::Error) -> Self {
383 Self::Http(HttpError(e))
384 }
385
386 /// Convert a [`tokio_tungstenite::tungstenite::Error`] into a
387 /// [`ClientError::WebSocket`] variant. See
388 /// [`from_reqwest`](Self::from_reqwest) for the SemVer rationale.
389 pub(crate) fn from_ws(e: tokio_tungstenite::tungstenite::Error) -> Self {
390 Self::WebSocket(WebSocketError(e))
391 }
392
393 /// Convert a [`reqwest::header::InvalidHeaderValue`] into a
394 /// [`ClientError::InvalidHeaderValue`] variant. The inner third-party
395 /// type carries no actionable diagnostic state, so we keep only the
396 /// `Display` representation as a `String`.
397 pub(crate) fn from_invalid_header(e: reqwest::header::InvalidHeaderValue) -> Self {
398 Self::InvalidHeaderValue(InvalidHeaderValueError {
399 message: e.to_string(),
400 })
401 }
402}
403
404// ---------------------------------------------------------------------------
405// Tests
406// ---------------------------------------------------------------------------
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 /// Verify ClientError variants by exhaustive match. Variant names are
413 /// part of the public API; this catches accidental rename / removal.
414 #[test]
415 fn client_error_exhaustive_match() {
416 let e = ClientError::InvalidArgument("test".into());
417 match e {
418 ClientError::Http(_) => {}
419 ClientError::InvalidHeaderValue(_) => {}
420 ClientError::AuthFailed(_) => {}
421 ClientError::Parse(_) => {}
422 ClientError::BlobIntegrityMismatch { .. } => {}
423 ClientError::InvalidArgument(_) => {}
424 ClientError::InvalidSession(_) => {}
425 ClientError::MethodNotFound(_) => {}
426 ClientError::MethodError { .. } => {}
427 ClientError::Serialize(_) => {}
428 ClientError::SseFrameTooLarge { .. } => {}
429 ClientError::ResponseTooLarge { .. } => {}
430 ClientError::WebSocket(_) => {}
431 ClientError::UnexpectedResponse(_) => {}
432 ClientError::RateLimited { .. } => {}
433 }
434 }
435
436 /// InvalidHeaderValueError preserves the underlying message so the
437 /// Display output matches the third-party error's Display verbatim —
438 /// callers that previously logged the `ClientError::InvalidHeaderValue`
439 /// variant see the same diagnostic text after the wrapper rename.
440 ///
441 /// Independent oracle: reqwest::header::InvalidHeaderValue is produced
442 /// by HeaderValue::from_str on bytes that are not valid header values
443 /// (e.g. embedded newline). The wrapper's Display is just the inner
444 /// type's Display.
445 #[test]
446 fn invalid_header_value_preserves_message() {
447 let inner_err = reqwest::header::HeaderValue::from_str("bad\nvalue")
448 .expect_err("newline must be rejected as a header value");
449 let inner_display = inner_err.to_string();
450
451 let ce = ClientError::from_invalid_header(inner_err);
452 let ClientError::InvalidHeaderValue(ihve) = &ce else {
453 panic!("must be InvalidHeaderValue variant, got {ce:?}");
454 };
455 assert_eq!(
456 ihve.message(),
457 inner_display,
458 "wrapper message must equal inner Display"
459 );
460 // Outer ClientError Display includes the prefix.
461 assert!(
462 ce.to_string().starts_with("invalid header value: "),
463 "ClientError Display must use the variant's #[error] prefix: {ce}"
464 );
465 }
466
467 /// An HttpError constructed from a reqwest builder failure exposes the
468 /// expected diagnostic accessor values: is_builder=true, status=None,
469 /// and a non-empty Display. Independent oracle: reqwest's documented
470 /// behaviour for invalid URLs (builder error, no status code).
471 #[test]
472 fn http_error_from_invalid_url_is_builder_error() {
473 // reqwest::Client::new().get("not a url") produces a builder error
474 // when the URL fails to parse. Building the request and calling
475 // .send() requires async context; .build() is synchronous and
476 // suffices to provoke a parse failure.
477 let client = reqwest::Client::new();
478 let build_err = client
479 .request(reqwest::Method::GET, "://not-a-url")
480 .build()
481 .expect_err("malformed URL must produce a build error");
482
483 let ce = ClientError::from_reqwest(build_err);
484 let ClientError::Http(http_err) = &ce else {
485 panic!("must be Http variant, got {ce:?}");
486 };
487 assert!(
488 http_err.is_builder(),
489 "malformed URL must be classified as a builder error"
490 );
491 assert!(
492 http_err.status().is_none(),
493 "builder errors carry no HTTP status"
494 );
495 assert!(
496 !http_err.is_timeout(),
497 "builder error must not classify as timeout"
498 );
499 assert!(
500 !http_err.is_connect(),
501 "builder error must not classify as connect"
502 );
503 assert!(
504 !http_err.to_string().is_empty(),
505 "Display must produce a non-empty diagnostic"
506 );
507 }
508
509 /// A WebSocketError wrapping ConnectionClosed correctly classifies via
510 /// its accessor methods. Independent oracle: tungstenite's documented
511 /// Error variants are matched directly via the matches! macro.
512 #[test]
513 fn websocket_error_classifies_connection_closed() {
514 let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
515 let ce = ClientError::from_ws(inner);
516 let ClientError::WebSocket(ws_err) = &ce else {
517 panic!("must be WebSocket variant, got {ce:?}");
518 };
519 assert!(ws_err.is_connection_closed());
520 assert!(!ws_err.is_already_closed());
521 assert!(!ws_err.is_io());
522 assert!(!ws_err.is_protocol());
523 assert!(!ws_err.is_capacity());
524 }
525
526 /// A WebSocketError wrapping an Io variant correctly classifies via
527 /// is_io. Independent oracle: tungstenite::Error::Io is the documented
528 /// wrapper for std::io::Error sources.
529 #[test]
530 fn websocket_error_classifies_io() {
531 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test");
532 let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
533 let ce = ClientError::from_ws(inner);
534 let ClientError::WebSocket(ws_err) = &ce else {
535 panic!("must be WebSocket variant, got {ce:?}");
536 };
537 assert!(ws_err.is_io());
538 assert!(!ws_err.is_connection_closed());
539 assert!(!ws_err.is_already_closed());
540 }
541
542 /// HttpError, WebSocketError, and InvalidHeaderValueError all implement
543 /// std::error::Error. This is a regression guard against any future
544 /// refactor that drops one of these impls (which would silently break
545 /// downstream code that iterates the source chain via Error::source).
546 #[test]
547 fn wrapper_types_implement_std_error() {
548 fn assert_error<E: StdError>() {}
549 assert_error::<HttpError>();
550 assert_error::<WebSocketError>();
551 assert_error::<InvalidHeaderValueError>();
552 }
553}