use std::error::Error as StdError;
use std::fmt;
#[non_exhaustive]
pub struct HttpError(reqwest::Error);
impl HttpError {
pub fn is_timeout(&self) -> bool {
self.0.is_timeout()
}
pub fn is_connect(&self) -> bool {
self.0.is_connect()
}
pub fn is_builder(&self) -> bool {
self.0.is_builder()
}
pub fn is_redirect(&self) -> bool {
self.0.is_redirect()
}
pub fn is_status(&self) -> bool {
self.0.is_status()
}
pub fn is_request(&self) -> bool {
self.0.is_request()
}
pub fn is_body(&self) -> bool {
self.0.is_body()
}
pub fn is_decode(&self) -> bool {
self.0.is_decode()
}
pub fn status(&self) -> Option<u16> {
self.0.status().map(|s| s.as_u16())
}
pub fn url(&self) -> Option<String> {
self.0.url().map(ToString::to_string)
}
}
impl fmt::Display for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl fmt::Debug for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("HttpError").field(&self.0).finish()
}
}
impl StdError for HttpError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.0)
}
}
#[non_exhaustive]
pub struct WebSocketError(tokio_tungstenite::tungstenite::Error);
impl WebSocketError {
pub fn is_connection_closed(&self) -> bool {
matches!(
&self.0,
tokio_tungstenite::tungstenite::Error::ConnectionClosed
)
}
pub fn is_already_closed(&self) -> bool {
matches!(
&self.0,
tokio_tungstenite::tungstenite::Error::AlreadyClosed
)
}
pub fn is_io(&self) -> bool {
matches!(&self.0, tokio_tungstenite::tungstenite::Error::Io(_))
}
pub fn is_protocol(&self) -> bool {
matches!(&self.0, tokio_tungstenite::tungstenite::Error::Protocol(_))
}
pub fn is_capacity(&self) -> bool {
matches!(&self.0, tokio_tungstenite::tungstenite::Error::Capacity(_))
}
pub fn is_url(&self) -> bool {
matches!(&self.0, tokio_tungstenite::tungstenite::Error::Url(_))
}
}
impl fmt::Display for WebSocketError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl fmt::Debug for WebSocketError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("WebSocketError").field(&self.0).finish()
}
}
impl StdError for WebSocketError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.0)
}
}
#[non_exhaustive]
pub struct InvalidHeaderValueError {
message: String,
}
impl InvalidHeaderValueError {
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for InvalidHeaderValueError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl fmt::Debug for InvalidHeaderValueError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InvalidHeaderValueError")
.field("message", &self.message)
.finish()
}
}
impl StdError for InvalidHeaderValueError {}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("HTTP error: {0}")]
Http(HttpError),
#[error("invalid header value: {0}")]
InvalidHeaderValue(InvalidHeaderValueError),
#[error("authentication or authorization failure: HTTP {0}")]
AuthFailed(u16),
#[error("parse error: {0}")]
Parse(serde_json::Error),
#[error("blob integrity check failed: expected {expected}, got {actual}")]
BlobIntegrityMismatch {
expected: String,
actual: String,
},
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("invalid session: {0}")]
InvalidSession(String),
#[error("method not found in response: {0}")]
MethodNotFound(String),
#[error("JMAP method error: {error_type}")]
MethodError {
error_type: String,
description: Option<String>,
},
#[error("serialization error: {0}")]
Serialize(serde_json::Error),
#[error("SSE frame too large (limit: {limit} bytes)")]
SseFrameTooLarge {
limit: usize,
},
#[error("response too large: {actual} bytes exceeds limit of {limit} bytes")]
ResponseTooLarge {
actual: u64,
limit: u64,
},
#[error("WebSocket error: {0}")]
WebSocket(WebSocketError),
#[error("unexpected server response: {0}")]
UnexpectedResponse(String),
#[error("rate limited; retry after {retry_after}")]
RateLimited {
retry_after: jmap_types::UTCDate,
},
}
impl ClientError {
pub(crate) fn from_reqwest(e: reqwest::Error) -> Self {
Self::Http(HttpError(e))
}
pub(crate) fn from_ws(e: tokio_tungstenite::tungstenite::Error) -> Self {
Self::WebSocket(WebSocketError(e))
}
pub(crate) fn from_invalid_header(e: reqwest::header::InvalidHeaderValue) -> Self {
Self::InvalidHeaderValue(InvalidHeaderValueError {
message: e.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_error_exhaustive_match() {
let e = ClientError::InvalidArgument("test".into());
match e {
ClientError::Http(_) => {}
ClientError::InvalidHeaderValue(_) => {}
ClientError::AuthFailed(_) => {}
ClientError::Parse(_) => {}
ClientError::BlobIntegrityMismatch { .. } => {}
ClientError::InvalidArgument(_) => {}
ClientError::InvalidSession(_) => {}
ClientError::MethodNotFound(_) => {}
ClientError::MethodError { .. } => {}
ClientError::Serialize(_) => {}
ClientError::SseFrameTooLarge { .. } => {}
ClientError::ResponseTooLarge { .. } => {}
ClientError::WebSocket(_) => {}
ClientError::UnexpectedResponse(_) => {}
ClientError::RateLimited { .. } => {}
}
}
#[test]
fn invalid_header_value_preserves_message() {
let inner_err = reqwest::header::HeaderValue::from_str("bad\nvalue")
.expect_err("newline must be rejected as a header value");
let inner_display = inner_err.to_string();
let ce = ClientError::from_invalid_header(inner_err);
let ClientError::InvalidHeaderValue(ihve) = &ce else {
panic!("must be InvalidHeaderValue variant, got {ce:?}");
};
assert_eq!(
ihve.message(),
inner_display,
"wrapper message must equal inner Display"
);
assert!(
ce.to_string().starts_with("invalid header value: "),
"ClientError Display must use the variant's #[error] prefix: {ce}"
);
}
#[test]
fn http_error_from_invalid_url_is_builder_error() {
let client = reqwest::Client::new();
let build_err = client
.request(reqwest::Method::GET, "://not-a-url")
.build()
.expect_err("malformed URL must produce a build error");
let ce = ClientError::from_reqwest(build_err);
let ClientError::Http(http_err) = &ce else {
panic!("must be Http variant, got {ce:?}");
};
assert!(
http_err.is_builder(),
"malformed URL must be classified as a builder error"
);
assert!(
http_err.status().is_none(),
"builder errors carry no HTTP status"
);
assert!(
!http_err.is_timeout(),
"builder error must not classify as timeout"
);
assert!(
!http_err.is_connect(),
"builder error must not classify as connect"
);
assert!(
!http_err.to_string().is_empty(),
"Display must produce a non-empty diagnostic"
);
}
#[test]
fn websocket_error_classifies_connection_closed() {
let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
let ce = ClientError::from_ws(inner);
let ClientError::WebSocket(ws_err) = &ce else {
panic!("must be WebSocket variant, got {ce:?}");
};
assert!(ws_err.is_connection_closed());
assert!(!ws_err.is_already_closed());
assert!(!ws_err.is_io());
assert!(!ws_err.is_protocol());
assert!(!ws_err.is_capacity());
}
#[test]
fn websocket_error_classifies_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "test");
let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
let ce = ClientError::from_ws(inner);
let ClientError::WebSocket(ws_err) = &ce else {
panic!("must be WebSocket variant, got {ce:?}");
};
assert!(ws_err.is_io());
assert!(!ws_err.is_connection_closed());
assert!(!ws_err.is_already_closed());
}
#[test]
fn wrapper_types_implement_std_error() {
fn assert_error<E: StdError>() {}
assert_error::<HttpError>();
assert_error::<WebSocketError>();
assert_error::<InvalidHeaderValueError>();
}
}