//! The error type and its wire shape (SPEC §14.3.2): `{ "code", "message", "field" }` with the HTTP status of the //! code. `message` is English developer text, never shown to users. use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Every error code of SPEC §14.3.2 (exhaustive), in the order of that table. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum ErrorCode { InvalidRequest, InviteInvalid, Unauthorized, DeviceRevoked, Forbidden, NotFound, HouseholdFull, HouseholdIncomplete, NameTaken, AccountNotEmpty, CategoryInUse, CategoryIsDefault, CannotRevokeCurrentDevice, NoAccounts, CursorAhead, PayloadTooLarge, UnsupportedMediaType, ValidationFailed, ImageInvalid, BatchTooLarge, ProtocolUnsupported, RateLimited, Internal, NotImplemented, Unavailable, } impl ErrorCode { /// Every code, in the order of SPEC §14.3.2. pub const ALL: [ErrorCode; 25] = [ Self::InvalidRequest, Self::InviteInvalid, Self::Unauthorized, Self::DeviceRevoked, Self::Forbidden, Self::NotFound, Self::HouseholdFull, Self::HouseholdIncomplete, Self::NameTaken, Self::AccountNotEmpty, Self::CategoryInUse, Self::CategoryIsDefault, Self::CannotRevokeCurrentDevice, Self::NoAccounts, Self::CursorAhead, Self::PayloadTooLarge, Self::UnsupportedMediaType, Self::ValidationFailed, Self::ImageInvalid, Self::BatchTooLarge, Self::ProtocolUnsupported, Self::RateLimited, Self::Internal, Self::NotImplemented, Self::Unavailable, ]; /// The HTTP status of the code (SPEC §14.3.2). pub fn status(self) -> StatusCode { match self { Self::InvalidRequest | Self::InviteInvalid => StatusCode::BAD_REQUEST, Self::Unauthorized | Self::DeviceRevoked => StatusCode::UNAUTHORIZED, Self::Forbidden => StatusCode::FORBIDDEN, Self::NotFound => StatusCode::NOT_FOUND, Self::HouseholdFull | Self::HouseholdIncomplete | Self::NameTaken | Self::AccountNotEmpty | Self::CategoryInUse | Self::CategoryIsDefault | Self::CannotRevokeCurrentDevice | Self::NoAccounts | Self::CursorAhead => StatusCode::CONFLICT, Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::ValidationFailed | Self::ImageInvalid | Self::BatchTooLarge => { StatusCode::UNPROCESSABLE_ENTITY } Self::ProtocolUnsupported => StatusCode::UPGRADE_REQUIRED, Self::RateLimited => StatusCode::TOO_MANY_REQUESTS, Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE, } } /// The snake_case token, as on the wire. pub fn as_str(self) -> &'static str { match self { Self::InvalidRequest => "invalid_request", Self::InviteInvalid => "invite_invalid", Self::Unauthorized => "unauthorized", Self::DeviceRevoked => "device_revoked", Self::Forbidden => "forbidden", Self::NotFound => "not_found", Self::HouseholdFull => "household_full", Self::HouseholdIncomplete => "household_incomplete", Self::NameTaken => "name_taken", Self::AccountNotEmpty => "account_not_empty", Self::CategoryInUse => "category_in_use", Self::CategoryIsDefault => "category_is_default", Self::CannotRevokeCurrentDevice => "cannot_revoke_current_device", Self::NoAccounts => "no_accounts", Self::CursorAhead => "cursor_ahead", Self::PayloadTooLarge => "payload_too_large", Self::UnsupportedMediaType => "unsupported_media_type", Self::ValidationFailed => "validation_failed", Self::ImageInvalid => "image_invalid", Self::BatchTooLarge => "batch_too_large", Self::ProtocolUnsupported => "protocol_unsupported", Self::RateLimited => "rate_limited", Self::Internal => "internal", Self::NotImplemented => "not_implemented", Self::Unavailable => "unavailable", } } } impl std::fmt::Display for ErrorCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// The wire shape of an error (SPEC §14.3.2). `field` is always present, `null` when no field is concerned. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[schema(as = Error)] pub struct ErrorBody { pub code: ErrorCode, pub message: String, #[schema(required = true)] pub field: Option, } /// An error a handler returns. Converts into the JSON response of SPEC §14.3.2. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("{code}: {message}")] pub struct AppError { pub code: ErrorCode, pub message: String, pub field: Option, /// Seconds for `Retry-After` (only with `rate_limited`, SPEC §8.6). pub retry_after_secs: Option, } impl AppError { pub fn new(code: ErrorCode, message: impl Into) -> Self { Self { code, message: message.into(), field: None, retry_after_secs: None, } } /// Sets the JSON key concerned. pub fn with_field(mut self, field: impl Into) -> Self { self.field = Some(field.into()); self } pub fn invalid_request(message: impl Into) -> Self { Self::new(ErrorCode::InvalidRequest, message) } /// `validation_failed` for `field` (SPEC §9.6). pub fn validation(field: impl Into, message: impl Into) -> Self { Self::new(ErrorCode::ValidationFailed, message).with_field(field) } pub fn not_found() -> Self { Self::new(ErrorCode::NotFound, "not found") } pub fn unauthorized() -> Self { Self::new( ErrorCode::Unauthorized, "missing, malformed or unknown bearer token", ) } pub fn device_revoked() -> Self { Self::new(ErrorCode::DeviceRevoked, "this device has been revoked") } pub fn forbidden() -> Self { Self::new(ErrorCode::Forbidden, "your role does not allow this action") } pub fn protocol_unsupported() -> Self { Self::new( ErrorCode::ProtocolUnsupported, "X-Kvitt-Protocol must be 1 on authenticated endpoints", ) } pub fn payload_too_large(limit_bytes: usize) -> Self { Self::new( ErrorCode::PayloadTooLarge, format!("request body exceeds {limit_bytes} bytes"), ) } pub fn not_implemented() -> Self { Self::new(ErrorCode::NotImplemented, "not implemented") } pub fn rate_limited(retry_after_secs: u64) -> Self { Self { retry_after_secs: Some(retry_after_secs), ..Self::new(ErrorCode::RateLimited, "too many requests") } } /// `internal`. The detail is logged here; the client sees only a request id (SPEC §14.3.2). pub fn internal(detail: impl std::fmt::Display) -> Self { let request_id = uuid::Uuid::now_v7(); tracing::error!(error_code = "internal", %request_id, detail = %detail, "internal error"); Self::new( ErrorCode::Internal, format!("internal error, request id {request_id}"), ) } pub fn unavailable(message: impl Into) -> Self { Self::new(ErrorCode::Unavailable, message) } /// The wire body. pub fn body(&self) -> ErrorBody { ErrorBody { code: self.code, message: self.message.clone(), field: self.field.clone(), } } } impl From for AppError { /// `SQLITE_BUSY` beyond `busy_timeout` and pool time-outs are `unavailable`; anything else is `internal`. fn from(error: sqlx::Error) -> Self { let busy = match &error { sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => true, sqlx::Error::Database(db) => db .code() .and_then(|c| c.parse::().ok()) .is_some_and(|c| c & 0xff == 5 || c & 0xff == 6), _ => false, }; if busy { tracing::warn!(error_code = "unavailable", detail = %error, "database busy"); Self::unavailable("database busy") } else { Self::internal(error) } } } impl From for AppError { fn from(error: sqlx::migrate::MigrateError) -> Self { Self::internal(error) } } /// The error code a response carried, stored in the response extensions for the request log (SPEC §8.7). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LoggedErrorCode(pub ErrorCode); impl IntoResponse for AppError { fn into_response(self) -> Response { let status = self.code.status(); let mut response = (status, crate::api::JsonBody(self.body())).into_response(); if let Some(secs) = self.retry_after_secs { response .headers_mut() .insert(header::RETRY_AFTER, HeaderValue::from(secs)); } response.extensions_mut().insert(LoggedErrorCode(self.code)); response } } #[cfg(test)] mod tests { use super::*; #[test] fn serde_token_matches_as_str_for_every_code() { for code in ErrorCode::ALL { assert_eq!( serde_json::to_value(code).unwrap(), serde_json::json!(code.as_str()) ); } } #[test] fn body_has_field_null() { let v = serde_json::to_value(AppError::not_implemented().body()).unwrap(); assert_eq!( v, serde_json::json!({"code": "not_implemented", "message": "not implemented", "field": null}) ); } }