Skip to main content

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}