Files
kvitt/server/crates/kvitt-server/src/error.rs
T
jacobh2 06f48ef9e9 M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:20 +02:00

318 lines
10 KiB
Rust

//! 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<String>,
}
/// 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<String>,
/// Seconds for `Retry-After` (only with `rate_limited`, SPEC §8.6).
pub retry_after_secs: Option<u64>,
}
impl AppError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> 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<String>) -> Self {
self.field = Some(field.into());
self
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::new(ErrorCode::InvalidRequest, message)
}
/// `validation_failed` for `field` (SPEC §9.6).
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> 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<String>) -> 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<sqlx::Error> 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::<i32>().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<sqlx::migrate::MigrateError> 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})
);
}
}