fabro/lib/apps/fabro-server/src/error.rs
2026-08-01 19:28:52 -04:00

266 lines
7.6 KiB
Rust

use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use fabro_api::types::ErrorResponseEntry;
use fabro_vault::Error as VaultError;
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Workflow(#[from] fabro_workflow::Error),
#[error(transparent)]
Agent(#[from] fabro_agent::Error),
#[error(transparent)]
Llm(#[from] fabro_llm::Error),
#[error(transparent)]
Store(#[from] fabro_store::Error),
#[error(transparent)]
Config(#[from] fabro_config::Error),
#[error(transparent)]
Vault(#[from] VaultError),
#[error("bad request: {0}")]
BadRequest(String),
#[error("authentication required")]
Unauthorized,
#[error("access denied")]
Forbidden,
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
#[error("bad gateway: {0}")]
BadGateway(String),
#[error("internal server error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Serialize)]
struct ErrorEntry {
status: String,
title: String,
detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>,
}
#[derive(Serialize)]
struct ErrorBody {
errors: Vec<ErrorEntry>,
}
/// Uniform API error response.
///
/// Serializes to `{"errors": [{"status": "4xx", "title": "...", "detail":
/// "..."}]}`.
#[derive(Clone, Debug)]
pub struct ApiError {
status: StatusCode,
detail: String,
code: Option<String>,
}
impl ApiError {
pub fn new(status: StatusCode, detail: impl Into<String>) -> Self {
Self {
status,
detail: detail.into(),
code: None,
}
}
pub fn with_code(
status: StatusCode,
detail: impl Into<String>,
code: impl Into<String>,
) -> Self {
Self {
status,
detail: detail.into(),
code: Some(code.into()),
}
}
pub fn not_found(detail: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, detail)
}
pub fn bad_request(detail: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, detail)
}
pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "Authentication required.")
}
pub fn unauthorized_with_code(detail: impl Into<String>, code: impl Into<String>) -> Self {
Self::with_code(StatusCode::UNAUTHORIZED, detail, code)
}
pub fn forbidden() -> Self {
Self::new(StatusCode::FORBIDDEN, "Access denied.")
}
pub fn forbidden_with_code(detail: impl Into<String>, code: impl Into<String>) -> Self {
Self::with_code(StatusCode::FORBIDDEN, detail, code)
}
pub fn status(&self) -> StatusCode {
self.status
}
pub(crate) fn detail(&self) -> &str {
&self.detail
}
pub(crate) fn code(&self) -> Option<&str> {
self.code.as_deref()
}
/// Convert into the OpenAPI-generated `ErrorResponseEntry` wire form. This
/// is used by endpoints that return per-item errors inside a larger payload
/// (e.g. batch lifecycle responses), where the outer response is `200` but
/// individual items carry structured failures.
pub fn into_response_entry(self) -> ErrorResponseEntry {
ErrorResponseEntry {
status: self.status.as_u16().to_string(),
title: self
.status
.canonical_reason()
.unwrap_or("Unknown")
.to_string(),
detail: self.detail,
code: self.code,
request_id: None,
}
}
}
impl From<Error> for ApiError {
fn from(err: Error) -> Self {
match err {
Error::BadRequest(msg) => Self::bad_request(msg),
Error::Unauthorized => Self::unauthorized(),
Error::Forbidden => Self::forbidden(),
Error::NotFound(msg) => Self::not_found(msg),
Error::Conflict(msg) => Self::new(StatusCode::CONFLICT, msg),
Error::ServiceUnavailable(msg) => Self::new(StatusCode::SERVICE_UNAVAILABLE, msg),
Error::BadGateway(msg) => Self::new(StatusCode::BAD_GATEWAY, msg),
Error::Workflow(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::Agent(err) => Self::new(StatusCode::BAD_GATEWAY, err.to_string()),
Error::Llm(err) => Self::from(err),
Error::Store(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::Config(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::Vault(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::Internal(msg) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, msg),
}
}
}
/// LLM errors split at the HTTP boundary: request-validation failures are the
/// caller's fault (400); non-validation LLM failures, including provider,
/// middleware, and local configuration failures, return 502.
impl From<fabro_llm::Error> for ApiError {
fn from(err: fabro_llm::Error) -> Self {
match err {
fabro_llm::Error::InvalidRequest { message } => Self::bad_request(message),
err => Self::new(StatusCode::BAD_GATEWAY, format!("LLM error: {err}")),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let title = self
.status
.canonical_reason()
.unwrap_or("Unknown")
.to_string();
let body = ErrorBody {
errors: vec![ErrorEntry {
status: self.status.as_u16().to_string(),
title,
detail: self.detail,
code: self.code,
}],
};
(self.status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use axum::body::to_bytes;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
use super::ApiError;
#[tokio::test]
async fn api_error_with_code_serializes_machine_readable_code() {
let response = ApiError::with_code(
StatusCode::UNAUTHORIZED,
"token expired",
"access_token_expired",
)
.into_response();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should serialize");
let body: serde_json::Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
body,
json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "token expired",
"code": "access_token_expired"
}]
})
);
}
#[tokio::test]
async fn unauthorized_without_code_omits_code_key() {
let response = ApiError::unauthorized().into_response();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should serialize");
let body: serde_json::Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
body,
json!({
"errors": [{
"status": "401",
"title": "Unauthorized",
"detail": "Authentication required."
}]
})
);
}
}