fabro/lib/apps/fabro-server/src/error.rs
Bryan Helmkamp 75e5f34c0d
Expose lithos request and response shapes through the API, server, CLI, and web
The OpenAPI spec adopts the lithos request, response, content part,
tool, usage, and cost schemas. The completions endpoint returns the
lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s
verbatim. The models and providers endpoints serve the fabro-types
catalog views, and the install and model-test flows probe providers
through fabro-llm.

The CLI builds its catalog from the operator overlay, drives `fabro exec`
through the server gateway adapter, and parses reasoning effort with the
shared controls. The web app reads content parts as lithos-tagged
objects. The TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:26:57 -06:00

268 lines
7.7 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.kind() {
fabro_llm::ErrorKind::InvalidRequest | fabro_llm::ErrorKind::ModelSelection => {
Self::bad_request(err.message().to_string())
}
_ => 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."
}]
})
);
}
}