use std::fmt;
use reqwest::StatusCode;
#[derive(Debug, Clone)]
pub struct ApiError {
pub status: StatusCode,
pub kind: ApiErrorKind,
pub message: Option<String>,
}
impl ApiError {
pub(crate) fn new(
status: StatusCode,
message: Option<String>,
retry_after_secs: Option<u64>,
) -> Self {
Self {
kind: ApiErrorKind::from_status(status, retry_after_secs),
status,
message,
}
}
pub(crate) fn with_kind(mut self, kind: ApiErrorKind) -> Self {
self.kind = kind;
self
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Lichess API error {}: {}",
self.status.as_u16(),
self.kind
)?;
if let Some(message) = &self.message {
write!(f, " - {message}")?;
}
Ok(())
}
}
impl std::error::Error for ApiError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorKind {
BadRequest,
Unauthorized,
Forbidden,
NotFound,
Conflict,
PayloadTooLarge,
RateLimited {
retry_after_secs: Option<u64>,
},
Server,
SwissUnauthorizedEdit,
Unexpected(u16),
}
impl ApiErrorKind {
fn from_status(status: StatusCode, retry_after_secs: Option<u64>) -> Self {
match status.as_u16() {
400 => Self::BadRequest,
401 => Self::Unauthorized,
403 => Self::Forbidden,
404 => Self::NotFound,
409 => Self::Conflict,
413 => Self::PayloadTooLarge,
429 => Self::RateLimited { retry_after_secs },
500..=599 => Self::Server,
other => Self::Unexpected(other),
}
}
}
impl fmt::Display for ApiErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadRequest => f.write_str("bad request"),
Self::Unauthorized => f.write_str("unauthorized"),
Self::Forbidden => f.write_str("forbidden"),
Self::NotFound => f.write_str("not found"),
Self::Conflict => f.write_str("conflict"),
Self::PayloadTooLarge => f.write_str("payload too large"),
Self::RateLimited {
retry_after_secs: Some(secs),
} => write!(f, "rate limited (retry after {secs}s)"),
Self::RateLimited {
retry_after_secs: None,
} => f.write_str("rate limited"),
Self::Server => f.write_str("server error"),
Self::SwissUnauthorizedEdit => f.write_str("not allowed to edit this swiss"),
Self::Unexpected(code) => write!(f, "unexpected status {code}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kind(code: u16) -> ApiErrorKind {
ApiErrorKind::from_status(StatusCode::from_u16(code).unwrap(), None)
}
#[test]
fn maps_documented_status_codes() {
assert_eq!(kind(400), ApiErrorKind::BadRequest);
assert_eq!(kind(401), ApiErrorKind::Unauthorized);
assert_eq!(kind(403), ApiErrorKind::Forbidden);
assert_eq!(kind(404), ApiErrorKind::NotFound);
assert_eq!(kind(409), ApiErrorKind::Conflict);
assert_eq!(kind(413), ApiErrorKind::PayloadTooLarge);
}
#[test]
fn maps_server_and_unexpected_ranges() {
assert_eq!(kind(500), ApiErrorKind::Server);
assert_eq!(kind(503), ApiErrorKind::Server);
assert_eq!(kind(418), ApiErrorKind::Unexpected(418));
}
#[test]
fn rate_limit_preserves_retry_after() {
let limited = ApiErrorKind::from_status(StatusCode::TOO_MANY_REQUESTS, Some(42));
assert_eq!(
limited,
ApiErrorKind::RateLimited {
retry_after_secs: Some(42)
}
);
}
#[test]
fn with_kind_reclassifies_but_keeps_status_and_message() {
let reclassified = ApiError::new(StatusCode::UNAUTHORIZED, Some("nope".into()), None)
.with_kind(ApiErrorKind::SwissUnauthorizedEdit);
assert_eq!(reclassified.kind, ApiErrorKind::SwissUnauthorizedEdit);
assert_eq!(reclassified.status, StatusCode::UNAUTHORIZED);
assert_eq!(reclassified.message.as_deref(), Some("nope"));
}
#[test]
fn display_includes_status_and_message() {
let err = ApiError::new(StatusCode::NOT_FOUND, Some("Not found.".into()), None);
assert_eq!(
err.to_string(),
"Lichess API error 404: not found - Not found."
);
}
}