jmap_base_client/error.rs
1#[non_exhaustive]
2#[derive(Debug, thiserror::Error)]
3pub enum ClientError {
4 /// Network or TLS error from the HTTP layer. May be retriable (transient
5 /// network failure) or permanent (TLS configuration error). Indicates a
6 /// network or transport problem, not a JMAP protocol error.
7 ///
8 /// **Semver note**: this variant embeds `reqwest::Error` directly. Callers
9 /// that match this variant are semver-locked to the same `reqwest` major
10 /// version as this crate. This is a known pre-1.0 limitation.
11 #[error("HTTP error: {0}")]
12 Http(#[from] reqwest::Error),
13
14 /// A header value could not be encoded. Indicates a caller bug — the
15 /// credential string contains characters that are not valid HTTP header
16 /// value characters. Not retriable.
17 #[error("invalid header value: {0}")]
18 InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
19
20 /// The server returned HTTP 401 (authentication failure) or 403
21 /// (authorization failure — credentials present but insufficient). Not
22 /// retriable without correcting credentials.
23 #[error("authentication or authorization failure: HTTP {0}")]
24 AuthFailed(u16),
25
26 /// A server response could not be parsed or did not match the expected
27 /// shape. Indicates the server sent a malformed response. Not retriable
28 /// without a server fix.
29 ///
30 /// Construct explicitly: `.map_err(ClientError::Parse)`.
31 #[error("parse error: {0}")]
32 Parse(serde_json::Error),
33
34 /// Downloaded blob SHA-256 does not match the expected digest. Indicates
35 /// in-transit corruption or a misbehaving server. Not retriable without
36 /// re-fetching metadata.
37 #[error("blob integrity check failed: expected {expected}, got {actual}")]
38 BlobIntegrityMismatch { expected: String, actual: String },
39
40 /// A caller-supplied argument violates a precondition (e.g. empty token,
41 /// colon in BasicAuth username, missing required filter field).
42 #[error("invalid argument: {0}")]
43 InvalidArgument(String),
44
45 /// The JMAP Session object from the server was missing a required field.
46 /// Indicates a server-side bug or incompatible server. Not retriable.
47 #[error("invalid session: {0}")]
48 InvalidSession(String),
49
50 /// The JMAP API response did not contain the expected method call ID.
51 /// Indicates a server-side bug or unexpected response shape.
52 #[error("method not found in response: {0}")]
53 MethodNotFound(String),
54
55 /// The JMAP server returned a method-level error object (RFC 8620 §3.6).
56 /// Retriability depends on `error_type` (e.g. `serverFail` may be
57 /// retried; `invalidArguments` is not retriable).
58 ///
59 /// `description` is `None` when the server omits the optional description field.
60 #[error("JMAP method error: {error_type}")]
61 MethodError {
62 error_type: String,
63 description: Option<String>,
64 },
65
66 /// A JMAP request could not be serialized to JSON when sending over
67 /// WebSocket. Indicates a caller bug — the data structure contains
68 /// non-serializable values. Not retriable.
69 ///
70 /// This error is only returned by [`WsSession::send_request`]; the HTTP
71 /// `call()` path delegates serialization to reqwest, which surfaces
72 /// serialization failures as [`ClientError::Http`].
73 ///
74 /// Construct explicitly: `.map_err(ClientError::Serialize)`.
75 #[error("serialization error: {0}")]
76 Serialize(serde_json::Error),
77
78 /// An SSE frame exceeded the configured buffer limit
79 /// ([`ClientConfig::max_sse_frame`]). The stream is terminated after this
80 /// error. Indicates a misbehaving or hostile server.
81 #[error("SSE frame too large (limit: {limit} bytes)")]
82 SseFrameTooLarge { limit: usize },
83
84 /// A server response body exceeded the enforced size limit. Protects
85 /// against unbounded memory allocation from malicious or buggy servers.
86 /// `actual` is in bytes (from Content-Length or actual read size).
87 #[error("response too large: {actual} bytes exceeds limit of {limit} bytes")]
88 ResponseTooLarge { actual: u64, limit: u64 },
89
90 /// A WebSocket transport error (connection, framing, or TLS). May be
91 /// retriable (transient network failure) or permanent (TLS config error).
92 ///
93 /// **Semver note**: this variant embeds `tungstenite::Error` directly.
94 /// Callers that match this variant are semver-locked to the same
95 /// `tokio-tungstenite` major version as this crate. Pre-1.0 limitation.
96 #[error("WebSocket error: {0}")]
97 WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
98
99 /// The server returned a response that violates the JMAP protocol (outside
100 /// the Session fetch path). Examples: wrong `Content-Type` on an SSE
101 /// connection, unexpected response shape on a non-session endpoint.
102 ///
103 /// Distinct from [`ClientError::InvalidSession`], which indicates a
104 /// problem with the Session document itself. Not retriable without a
105 /// server fix.
106 #[error("unexpected server response: {0}")]
107 UnexpectedResponse(String),
108}
109
110// ---------------------------------------------------------------------------
111// Tests
112// ---------------------------------------------------------------------------
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 /// Verify ClientError does not have a RateLimited variant by exhaustive match.
119 /// This match will fail to compile if RateLimited is ever reintroduced.
120 #[test]
121 fn client_error_no_rate_limited_variant() {
122 let e = ClientError::InvalidArgument("test".into());
123 match e {
124 ClientError::Http(_) => {}
125 ClientError::InvalidHeaderValue(_) => {}
126 ClientError::AuthFailed(_) => {}
127 ClientError::Parse(_) => {}
128 ClientError::BlobIntegrityMismatch { .. } => {}
129 ClientError::InvalidArgument(_) => {}
130 ClientError::InvalidSession(_) => {}
131 ClientError::MethodNotFound(_) => {}
132 ClientError::MethodError { .. } => {}
133 ClientError::Serialize(_) => {}
134 ClientError::SseFrameTooLarge { .. } => {}
135 ClientError::ResponseTooLarge { .. } => {}
136 ClientError::WebSocket(_) => {}
137 ClientError::UnexpectedResponse(_) => {}
138 }
139 }
140}