#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ErrorCode {
ServiceUnavailable,
ConnectionFailed,
Timeout,
RateLimited,
NotFound,
AlreadyExists,
Conflict,
InvalidInput,
MissingField,
InvalidFormat,
Unauthorized,
Forbidden,
TokenExpired,
InvalidToken,
#[serde(rename = "INTERNAL_ERROR")]
Internal,
DatabaseError,
#[serde(rename = "EXTERNAL_SERVICE_ERROR")]
ExternalService,
Cancelled,
}
impl ErrorCode {
pub fn is_retryable(self) -> bool {
matches!(
self,
ErrorCode::ServiceUnavailable
| ErrorCode::ConnectionFailed
| ErrorCode::Timeout
| ErrorCode::RateLimited
| ErrorCode::ExternalService
)
}
pub fn http_status(self) -> http::StatusCode {
match self {
ErrorCode::ServiceUnavailable => http::StatusCode::SERVICE_UNAVAILABLE,
ErrorCode::ConnectionFailed => http::StatusCode::BAD_GATEWAY,
ErrorCode::Timeout => http::StatusCode::GATEWAY_TIMEOUT,
ErrorCode::RateLimited => http::StatusCode::TOO_MANY_REQUESTS,
ErrorCode::NotFound => http::StatusCode::NOT_FOUND,
ErrorCode::AlreadyExists => http::StatusCode::CONFLICT,
ErrorCode::Conflict => http::StatusCode::CONFLICT,
ErrorCode::InvalidInput | ErrorCode::MissingField | ErrorCode::InvalidFormat => {
http::StatusCode::UNPROCESSABLE_ENTITY
}
ErrorCode::Unauthorized => http::StatusCode::UNAUTHORIZED,
ErrorCode::Forbidden => http::StatusCode::FORBIDDEN,
ErrorCode::TokenExpired | ErrorCode::InvalidToken => http::StatusCode::UNAUTHORIZED,
ErrorCode::Internal | ErrorCode::DatabaseError => {
http::StatusCode::INTERNAL_SERVER_ERROR
}
ErrorCode::ExternalService => http::StatusCode::BAD_GATEWAY,
ErrorCode::Cancelled => http::StatusCode::REQUEST_TIMEOUT,
#[allow(unreachable_patterns)]
_ => http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::ServiceUnavailable => "SERVICE_UNAVAILABLE",
ErrorCode::ConnectionFailed => "CONNECTION_FAILED",
ErrorCode::Timeout => "TIMEOUT",
ErrorCode::RateLimited => "RATE_LIMITED",
ErrorCode::NotFound => "NOT_FOUND",
ErrorCode::AlreadyExists => "ALREADY_EXISTS",
ErrorCode::Conflict => "CONFLICT",
ErrorCode::InvalidInput => "INVALID_INPUT",
ErrorCode::MissingField => "MISSING_FIELD",
ErrorCode::InvalidFormat => "INVALID_FORMAT",
ErrorCode::Unauthorized => "UNAUTHORIZED",
ErrorCode::Forbidden => "FORBIDDEN",
ErrorCode::TokenExpired => "TOKEN_EXPIRED",
ErrorCode::InvalidToken => "INVALID_TOKEN",
ErrorCode::Internal => "INTERNAL_ERROR",
ErrorCode::DatabaseError => "DATABASE_ERROR",
ErrorCode::ExternalService => "EXTERNAL_SERVICE_ERROR",
ErrorCode::Cancelled => "CANCELLED",
#[allow(unreachable_patterns)]
_ => "UNKNOWN",
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retryable_service_unavailable() {
assert!(ErrorCode::ServiceUnavailable.is_retryable());
}
#[test]
fn retryable_connection_failed() {
assert!(ErrorCode::ConnectionFailed.is_retryable());
}
#[test]
fn retryable_timeout() {
assert!(ErrorCode::Timeout.is_retryable());
}
#[test]
fn retryable_rate_limited() {
assert!(ErrorCode::RateLimited.is_retryable());
}
#[test]
fn retryable_external_service() {
assert!(ErrorCode::ExternalService.is_retryable());
}
#[test]
fn not_retryable_not_found() {
assert!(!ErrorCode::NotFound.is_retryable());
}
#[test]
fn not_retryable_already_exists() {
assert!(!ErrorCode::AlreadyExists.is_retryable());
}
#[test]
fn not_retryable_conflict() {
assert!(!ErrorCode::Conflict.is_retryable());
}
#[test]
fn not_retryable_invalid_input() {
assert!(!ErrorCode::InvalidInput.is_retryable());
}
#[test]
fn not_retryable_missing_field() {
assert!(!ErrorCode::MissingField.is_retryable());
}
#[test]
fn not_retryable_invalid_format() {
assert!(!ErrorCode::InvalidFormat.is_retryable());
}
#[test]
fn not_retryable_unauthorized() {
assert!(!ErrorCode::Unauthorized.is_retryable());
}
#[test]
fn not_retryable_forbidden() {
assert!(!ErrorCode::Forbidden.is_retryable());
}
#[test]
fn not_retryable_token_expired() {
assert!(!ErrorCode::TokenExpired.is_retryable());
}
#[test]
fn not_retryable_invalid_token() {
assert!(!ErrorCode::InvalidToken.is_retryable());
}
#[test]
fn not_retryable_internal() {
assert!(!ErrorCode::Internal.is_retryable());
}
#[test]
fn not_retryable_database_error() {
assert!(!ErrorCode::DatabaseError.is_retryable());
}
#[test]
fn http_status_not_found_is_404() {
assert_eq!(
ErrorCode::NotFound.http_status(),
http::StatusCode::NOT_FOUND
);
}
#[test]
fn http_status_unauthorized_is_401() {
assert_eq!(
ErrorCode::Unauthorized.http_status(),
http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn http_status_internal_is_500() {
assert_eq!(
ErrorCode::Internal.http_status(),
http::StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn http_status_rate_limited_is_429() {
assert_eq!(
ErrorCode::RateLimited.http_status(),
http::StatusCode::TOO_MANY_REQUESTS
);
}
#[test]
fn as_str_connection_failed() {
assert_eq!(ErrorCode::ConnectionFailed.as_str(), "CONNECTION_FAILED");
}
#[test]
fn as_str_not_found() {
assert_eq!(ErrorCode::NotFound.as_str(), "NOT_FOUND");
}
#[test]
fn as_str_unauthorized() {
assert_eq!(ErrorCode::Unauthorized.as_str(), "UNAUTHORIZED");
}
#[test]
fn as_str_internal() {
assert_eq!(ErrorCode::Internal.as_str(), "INTERNAL_ERROR");
}
#[test]
fn display_uses_as_str() {
assert_eq!(format!("{}", ErrorCode::Timeout), "TIMEOUT");
}
#[test]
fn serde_repr_matches_as_str_for_all_variants() {
const ALL: &[ErrorCode] = &[
ErrorCode::ServiceUnavailable,
ErrorCode::ConnectionFailed,
ErrorCode::Timeout,
ErrorCode::RateLimited,
ErrorCode::NotFound,
ErrorCode::AlreadyExists,
ErrorCode::Conflict,
ErrorCode::InvalidInput,
ErrorCode::MissingField,
ErrorCode::InvalidFormat,
ErrorCode::Unauthorized,
ErrorCode::Forbidden,
ErrorCode::TokenExpired,
ErrorCode::InvalidToken,
ErrorCode::Internal,
ErrorCode::DatabaseError,
ErrorCode::ExternalService,
ErrorCode::Cancelled,
];
for code in ALL {
let json = serde_json::to_string(code).expect("serialize");
let wire = json.trim_matches('"');
assert_eq!(wire, code.as_str(), "serde/as_str drift for {code:?}");
let back: ErrorCode = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, *code, "roundtrip drift for {code:?}");
}
}
}