fabro/lib/apps/fabro-server/src/error.rs
Bryan Helmkamp 0820252bcf
Add the Petri run store endpoints to the API
The worker shape of the integration plan (F1.3) needs a run's worker to
reach the run's Petri records over the server's API. This adds the
contract: six worker-scoped endpoints under `/api/v1/runs/{id}/petri/`
(open, release, list and append records of one log, write and read a
blob), their request and response schemas, and the generated Rust and
TypeScript clients.

A store error needs more than a code: `petri_run_leased` names the
holding owner and `petri_record_conflict` names the refused position.
`ErrorResponseEntry` gains an optional `meta` object for such
code-specific members, `ApiError` can carry it, and the client's
`ApiFailure` parses it beside the code so a caller can act on it.

Records travel as `{seq, recorded_at, record}`, the store's own unit,
with `seq` and `recorded_at` as `uint64`. The log path segment is the
log id's text (`coordinator`, `resources`, `execution <n>`), which the
generated client percent-encodes. The blob write reuses
`WriteBlobResponse`, since Petri's digest is Fabro's blob hash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:12:20 -04:00

325 lines
9.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;
use serde_json::{Map, Value};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Workflow(#[from] fabro_workflow::Error),
#[error(transparent)]
Agent(#[from] pebble_coding_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>,
#[serde(skip_serializing_if = "Option::is_none")]
meta: Option<Box<Map<String, Value>>>,
}
#[derive(Serialize)]
struct ErrorBody {
errors: Vec<ErrorEntry>,
}
/// Uniform API error response.
///
/// Serializes to `{"errors": [{"status": "4xx", "title": "...", "detail":
/// "..."}]}`, with `code` and `meta` when the error carries them.
#[derive(Clone, Debug)]
pub struct ApiError {
status: StatusCode,
detail: String,
code: Option<String>,
/// Structured details specific to `code`, for clients that act on them.
/// Boxed so the error stays small in every `Result` that carries it.
meta: Option<Box<Map<String, Value>>>,
}
impl ApiError {
pub fn new(status: StatusCode, detail: impl Into<String>) -> Self {
Self {
status,
detail: detail.into(),
code: None,
meta: 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()),
meta: None,
}
}
/// An error whose `code` documents the members of `meta`.
pub fn with_code_and_meta(
status: StatusCode,
detail: impl Into<String>,
code: impl Into<String>,
meta: Map<String, Value>,
) -> Self {
Self {
status,
detail: detail.into(),
code: Some(code.into()),
meta: Some(Box::new(meta)),
}
}
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,
meta: self.meta.map(|meta| *meta).unwrap_or_default(),
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,
meta: self.meta,
}],
};
(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 api_error_with_meta_serializes_the_members_beside_the_code() {
let mut meta = serde_json::Map::new();
meta.insert("owner".to_string(), json!("worker-1"));
let response = ApiError::with_code_and_meta(
StatusCode::CONFLICT,
"run is leased",
"petri_run_leased",
meta,
)
.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": "409",
"title": "Conflict",
"detail": "run is leased",
"code": "petri_run_leased",
"meta": { "owner": "worker-1" }
}]
})
);
}
#[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."
}]
})
);
}
}