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)
}
pub fn kind(&self) -> HttpErrorKind {
if self.0.is_timeout() {
HttpErrorKind::Timeout
} else if self.0.is_connect() {
HttpErrorKind::Connect
} else if self.0.is_redirect() {
HttpErrorKind::Redirect
} else if let Some(s) = self.0.status() {
HttpErrorKind::Status(s.as_u16())
} else if self.0.is_request() {
HttpErrorKind::RequestBody
} else if self.0.is_body() {
HttpErrorKind::ResponseBody
} else if self.0.is_decode() {
HttpErrorKind::Decode
} else if self.0.is_builder() {
HttpErrorKind::Builder
} else {
HttpErrorKind::Other
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum HttpErrorKind {
Timeout,
Connect,
Redirect,
Status(u16),
RequestBody,
ResponseBody,
Decode,
Builder,
Other,
}
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(_))
}
pub fn kind(&self) -> WebSocketErrorKind {
use tokio_tungstenite::tungstenite::Error as TError;
match &self.0 {
TError::ConnectionClosed => WebSocketErrorKind::ConnectionClosed,
TError::AlreadyClosed => WebSocketErrorKind::AlreadyClosed,
TError::Url(_) => WebSocketErrorKind::Url,
TError::Protocol(_) => WebSocketErrorKind::Protocol,
TError::Capacity(_) => WebSocketErrorKind::Capacity,
TError::Io(_) => WebSocketErrorKind::Io,
_ => WebSocketErrorKind::Other,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WebSocketErrorKind {
ConnectionClosed,
AlreadyClosed,
Url,
Protocol,
Capacity,
Io,
Other,
}
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]
pub struct ParseError(serde_json::Error);
impl ParseError {
pub fn from_serde_json(e: serde_json::Error) -> Self {
Self(e)
}
pub fn line(&self) -> usize {
self.0.line()
}
pub fn column(&self) -> usize {
self.0.column()
}
pub fn classify(&self) -> ParseCategory {
ParseCategory::from(self.0.classify())
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl fmt::Debug for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("ParseError").field(&self.0).finish()
}
}
impl StdError for ParseError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.0)
}
}
#[non_exhaustive]
pub struct SerializeError(serde_json::Error);
impl SerializeError {
pub fn from_serde_json(e: serde_json::Error) -> Self {
Self(e)
}
pub fn line(&self) -> usize {
self.0.line()
}
pub fn column(&self) -> usize {
self.0.column()
}
pub fn classify(&self) -> ParseCategory {
ParseCategory::from(self.0.classify())
}
}
impl fmt::Display for SerializeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl fmt::Debug for SerializeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("SerializeError").field(&self.0).finish()
}
}
impl StdError for SerializeError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.0)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ParseCategory {
Io,
Syntax,
Data,
Eof,
}
impl From<serde_json::error::Category> for ParseCategory {
fn from(cat: serde_json::error::Category) -> Self {
match cat {
serde_json::error::Category::Io => Self::Io,
serde_json::error::Category::Syntax => Self::Syntax,
serde_json::error::Category::Data => Self::Data,
serde_json::error::Category::Eof => Self::Eof,
}
}
}
#[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(ParseError),
#[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(SerializeError),
#[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(),
})
}
pub fn from_parse(e: serde_json::Error) -> Self {
Self::Parse(ParseError(e))
}
pub fn from_serialize(e: serde_json::Error) -> Self {
Self::Serialize(SerializeError(e))
}
}
#[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>();
assert_error::<ParseError>();
assert_error::<SerializeError>();
}
#[test]
fn parse_error_classifies_syntax_failure() {
let inner =
serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
let ce = ClientError::from_parse(inner);
let ClientError::Parse(pe) = &ce else {
panic!("must be Parse variant, got {ce:?}");
};
assert!(
matches!(pe.classify(), ParseCategory::Syntax | ParseCategory::Eof),
"expected Syntax or Eof, got {:?}",
pe.classify()
);
assert!(pe.line() > 0, "line must be 1-based and non-zero");
}
#[test]
fn parse_error_classifies_data_failure() {
let inner = serde_json::from_str::<u32>("\"not a number\"")
.expect_err("string must fail to deserialise as u32");
let ce = ClientError::from_parse(inner);
let ClientError::Parse(pe) = &ce else {
panic!("must be Parse variant, got {ce:?}");
};
assert_eq!(pe.classify(), ParseCategory::Data);
}
#[test]
fn serialize_error_wraps_non_string_map_key() {
let mut map: std::collections::BTreeMap<(i32, i32), &str> =
std::collections::BTreeMap::new();
map.insert((1, 2), "value");
let inner = serde_json::to_string(&map)
.expect_err("tuple-keyed map must not serialise as a JSON object");
let ce = ClientError::from_serialize(inner);
let ClientError::Serialize(se) = &ce else {
panic!("must be Serialize variant, got {ce:?}");
};
assert!(
!se.to_string().is_empty(),
"SerializeError Display must be non-empty"
);
}
#[test]
fn parse_error_debug_format_uses_wrapper_name() {
let inner =
serde_json::from_str::<serde_json::Value>("{").expect_err("must fail to parse '{'");
let ce = ClientError::from_parse(inner);
let ClientError::Parse(pe) = &ce else {
panic!("must be Parse variant");
};
let debug = format!("{pe:?}");
assert!(
debug.starts_with("ParseError("),
"Debug must use the wrapper tuple-struct name: {debug}"
);
}
#[test]
fn serialize_error_debug_format_uses_wrapper_name() {
let mut map: std::collections::BTreeMap<(i32, i32), &str> =
std::collections::BTreeMap::new();
map.insert((1, 2), "value");
let inner = serde_json::to_string(&map)
.expect_err("tuple-keyed map must not serialise as a JSON object");
let ce = ClientError::from_serialize(inner);
let ClientError::Serialize(se) = &ce else {
panic!("must be Serialize variant");
};
let debug = format!("{se:?}");
assert!(
debug.starts_with("SerializeError("),
"Debug must use the wrapper tuple-struct name: {debug}"
);
}
#[test]
fn http_error_kind_exhaustive_match() {
let k = HttpErrorKind::Other;
match k {
HttpErrorKind::Timeout => {}
HttpErrorKind::Connect => {}
HttpErrorKind::Redirect => {}
HttpErrorKind::Status(_) => {}
HttpErrorKind::RequestBody => {}
HttpErrorKind::ResponseBody => {}
HttpErrorKind::Decode => {}
HttpErrorKind::Builder => {}
HttpErrorKind::Other => {}
}
}
#[test]
fn ws_error_kind_exhaustive_match() {
let k = WebSocketErrorKind::Other;
match k {
WebSocketErrorKind::ConnectionClosed => {}
WebSocketErrorKind::AlreadyClosed => {}
WebSocketErrorKind::Url => {}
WebSocketErrorKind::Protocol => {}
WebSocketErrorKind::Capacity => {}
WebSocketErrorKind::Io => {}
WebSocketErrorKind::Other => {}
}
}
#[test]
fn ws_error_kind_classifies_connection_closed() {
let inner = tokio_tungstenite::tungstenite::Error::ConnectionClosed;
let ws = WebSocketError(inner);
assert_eq!(ws.kind(), WebSocketErrorKind::ConnectionClosed);
}
#[test]
fn ws_error_kind_classifies_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test");
let inner = tokio_tungstenite::tungstenite::Error::Io(io_err);
let ws = WebSocketError(inner);
assert_eq!(ws.kind(), WebSocketErrorKind::Io);
}
#[test]
fn http_error_kind_classifies_builder_error() {
let client = reqwest::ClientBuilder::new().build().expect("build");
let req_err = client
.request(reqwest::Method::GET, "not a url")
.build()
.expect_err("malformed URL must produce a request builder error");
assert!(
req_err.is_builder(),
"reqwest invariant: malformed-URL build is a builder error"
);
let http = HttpError(req_err);
assert_eq!(
http.kind(),
HttpErrorKind::Builder,
"HttpError::kind must classify a builder-side error as Builder"
);
}
}