use std::error::Error as StdError;
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
#[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: {0}")]
MethodError(jmap_types::JmapError),
#[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("WebSocket closed by server: code {code}: {reason}")]
WsClosed {
code: u16,
reason: String,
},
#[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))
}
pub(crate) fn rate_limited(retry_after: Option<&str>, now: SystemTime) -> Self {
let now_secs = now.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
let at = retry_after.map(str::trim).and_then(|v| {
if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
Some(
v.parse::<u64>()
.map_or(u64::MAX, |d| now_secs.saturating_add(d)),
)
} else {
let t = httpdate::parse_http_date(v).ok()?;
t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
}
});
Self::RateLimited {
retry_after: utcdate_from_epoch(at.unwrap_or(now_secs)),
}
}
}
pub fn to_args<T: serde::Serialize + ?Sized>(value: &T) -> Result<serde_json::Value, ClientError> {
serde_json::to_value(value).map_err(ClientError::from_serialize)
}
fn utcdate_from_epoch(secs: u64) -> jmap_types::UTCDate {
let secs = secs.min(253_402_300_799);
let (days, rem) = (secs / 86_400, secs % 86_400);
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + u64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
rem / 60 % 60,
rem % 60
)
.into()
}
#[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::WsClosed { .. } => {}
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 to_args_maps_failure_to_serialize_variant() {
let mut map: std::collections::BTreeMap<(i32, i32), &str> =
std::collections::BTreeMap::new();
map.insert((1, 2), "value");
let err = to_args(&map).expect_err("tuple-keyed map has no JSON form");
assert!(
matches!(err, ClientError::Serialize(_)),
"must be Serialize variant, got {err:?}"
);
}
#[test]
fn to_args_returns_json_value() {
let v = to_args(&["a1", "b2"][..]).expect("string slice serializes");
assert_eq!(v, serde_json::json!(["a1", "b2"]));
}
#[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"
);
}
fn rfc8620_now() -> SystemTime {
UNIX_EPOCH + std::time::Duration::from_secs(1_414_649_520)
}
fn retry_after_of(e: ClientError) -> String {
match e {
ClientError::RateLimited { retry_after } => retry_after.into_inner(),
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[test]
fn rate_limited_accepts_all_three_http_date_formats() {
for v in [
"Sun, 06 Nov 1994 08:49:37 GMT",
"Sunday, 06-Nov-94 08:49:37 GMT",
"Sun Nov 6 08:49:37 1994",
] {
assert_eq!(
retry_after_of(ClientError::rate_limited(Some(v), rfc8620_now())),
"1994-11-06T08:49:37Z",
"Retry-After: {v}"
);
}
}
#[test]
fn rate_limited_parses_rfc9110_retry_after_examples() {
assert_eq!(
retry_after_of(ClientError::rate_limited(
Some("Fri, 31 Dec 1999 23:59:59 GMT"),
rfc8620_now()
)),
"1999-12-31T23:59:59Z"
);
assert_eq!(
retry_after_of(ClientError::rate_limited(Some("120"), rfc8620_now())),
"2014-10-30T06:14:00Z"
);
}
#[test]
fn rate_limited_falls_back_to_now_without_usable_value() {
for v in [None, Some(""), Some("+120"), Some("soon")] {
assert_eq!(
retry_after_of(ClientError::rate_limited(v, rfc8620_now())),
"2014-10-30T06:12:00Z",
"Retry-After: {v:?}"
);
}
}
#[test]
fn rate_limited_clamps_huge_delay() {
assert_eq!(
retry_after_of(ClientError::rate_limited(
Some("99999999999999999999999"),
rfc8620_now()
)),
"9999-12-31T23:59:59Z"
);
}
#[test]
fn utcdate_from_epoch_matches_independent_inverse() {
assert_eq!(utcdate_from_epoch(951_825_600), "2000-02-29T12:00:00Z");
assert_eq!(utcdate_from_epoch(784_111_777), "1994-11-06T08:49:37Z");
let mut s = 0u64;
while s <= 253_402_300_799 {
let d = utcdate_from_epoch(s);
let parsed = jmap_types::UTCDate::new_validated(d.as_ref())
.unwrap_or_else(|e| panic!("{d} invalid: {e}"));
assert_eq!(parsed.to_epoch_seconds().unwrap(), s as i64, "{d}");
s += 9_999_991;
}
}
}