use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
NotFound(String),
BadRequest(String),
Internal(String),
#[allow(dead_code)]
Unauthorized(String),
Forbidden(String),
ServiceUnavailable(String),
PayloadTooLarge {
size: usize,
limit: usize,
},
GatewayTimeout(String),
Conflict(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::NotFound(m) => write!(f, "Not Found: {m}"),
AppError::BadRequest(m) => write!(f, "Bad Request: {m}"),
AppError::Internal(m) => write!(f, "Internal Error: {m}"),
AppError::Unauthorized(m) => write!(f, "Unauthorized: {m}"),
AppError::Forbidden(m) => write!(f, "Forbidden: {m}"),
AppError::ServiceUnavailable(m) => write!(f, "Service Unavailable: {m}"),
AppError::PayloadTooLarge { size, limit } => write!(
f,
"Payload too large: {size} bytes exceeds limit of {limit} bytes"
),
AppError::GatewayTimeout(m) => write!(f, "Gateway Timeout: {m}"),
AppError::Conflict(m) => write!(f, "Conflict: {m}"),
}
}
}
impl std::error::Error for AppError {}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
AppError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()),
AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
AppError::ServiceUnavailable(m) => (StatusCode::SERVICE_UNAVAILABLE, m.clone()),
AppError::PayloadTooLarge { size, limit } => (
StatusCode::PAYLOAD_TOO_LARGE,
format!("{size} bytes exceeds limit of {limit} bytes"),
),
AppError::GatewayTimeout(m) => (StatusCode::GATEWAY_TIMEOUT, m.clone()),
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
};
let body = json!({ "error": message });
(status, axum::Json(body)).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
for source in err.chain() {
if let Some(ke) = source.downcast_ref::<oxios_kernel::error::KernelError>() {
match ke.http_status() {
oxios_kernel::error::HttpStatus::NotFound => {
return AppError::NotFound(err.to_string());
}
oxios_kernel::error::HttpStatus::BadRequest => {
return AppError::BadRequest(err.to_string());
}
oxios_kernel::error::HttpStatus::Forbidden => {
return AppError::Forbidden(err.to_string());
}
_ => {}
}
}
}
AppError::Internal(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
assert_eq!(
AppError::NotFound("test".into()).to_string(),
"Not Found: test"
);
assert_eq!(
AppError::BadRequest("bad".into()).to_string(),
"Bad Request: bad"
);
}
#[test]
fn test_anyhow_conversion() {
let err: AppError = anyhow::anyhow!("something failed").into();
match err {
AppError::Internal(msg) => assert_eq!(msg, "something failed"),
_ => panic!("Expected Internal variant"),
}
}
#[test]
fn test_kernel_not_found_preserved() {
let kernel_err = oxios_kernel::error::KernelError::SessionNotFound { id: "s1".into() };
let anyhow_err: anyhow::Error = kernel_err.into();
let app_err: AppError = anyhow_err.into();
assert!(
matches!(app_err, AppError::NotFound(_)),
"SessionNotFound should map to NotFound, not Internal"
);
}
#[test]
fn test_kernel_not_found_through_context() {
let kernel_err = oxios_kernel::error::KernelError::SessionNotFound { id: "s2".into() };
let anyhow_err = anyhow::Error::new(kernel_err).context("while fetching session");
let app_err: AppError = anyhow_err.into();
assert!(
matches!(app_err, AppError::NotFound(_)),
"context-wrapped SessionNotFound should still map to NotFound"
);
}
}