Skip to main content

cellos_server/
error.rs

1//! RFC 9457 Problem Details for HTTP APIs.
2//!
3//! Every error path in the server returns `application/problem+json` so
4//! that `cellctl` (and the web UI) can render structured diagnostics
5//! without parsing free-form strings. The `type` field is a stable
6//! identifier — clients may switch on it; the `title`/`detail` fields are
7//! human-readable and may change.
8
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use serde::Serialize;
13
14/// Stable error identifier. Adding a variant is a non-breaking change;
15/// renaming one IS breaking (clients pin on `type`).
16#[derive(Debug, Clone, Copy)]
17pub enum AppErrorKind {
18    Unauthorized,
19    BadRequest,
20    NotFound,
21    Conflict,
22    Internal,
23    /// Discriminants from ADR-0010 §Enforcement: cellos-server admission
24    /// gate rejection reasons. Surfaced via `application/problem+json`
25    /// so cellctl can switch on `type` without parsing `detail`.
26    FormationCycle,
27    FormationMultipleCoordinators,
28    FormationNoCoordinator,
29    FormationAuthorityNotNarrowing,
30}
31
32impl AppErrorKind {
33    pub fn status(self) -> StatusCode {
34        match self {
35            AppErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
36            AppErrorKind::BadRequest
37            | AppErrorKind::FormationCycle
38            | AppErrorKind::FormationMultipleCoordinators
39            | AppErrorKind::FormationNoCoordinator
40            | AppErrorKind::FormationAuthorityNotNarrowing => StatusCode::BAD_REQUEST,
41            AppErrorKind::NotFound => StatusCode::NOT_FOUND,
42            AppErrorKind::Conflict => StatusCode::CONFLICT,
43            AppErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
44        }
45    }
46
47    /// `type` URI identifier per RFC 9457 §3.1. We use relative URI
48    /// references rooted at `/problems/` so the server's deployment URL
49    /// does not affect the stable identifier.
50    pub fn type_uri(self) -> &'static str {
51        match self {
52            AppErrorKind::Unauthorized => "/problems/unauthorized",
53            AppErrorKind::BadRequest => "/problems/bad-request",
54            AppErrorKind::NotFound => "/problems/not-found",
55            AppErrorKind::Conflict => "/problems/conflict",
56            AppErrorKind::Internal => "/problems/internal",
57            AppErrorKind::FormationCycle => "/problems/formation/cycle",
58            AppErrorKind::FormationMultipleCoordinators => {
59                "/problems/formation/multiple-coordinators"
60            }
61            AppErrorKind::FormationNoCoordinator => "/problems/formation/no-coordinator",
62            AppErrorKind::FormationAuthorityNotNarrowing => {
63                "/problems/formation/authority-not-narrowing"
64            }
65        }
66    }
67
68    pub fn title(self) -> &'static str {
69        match self {
70            AppErrorKind::Unauthorized => "Unauthorized",
71            AppErrorKind::BadRequest => "Bad Request",
72            AppErrorKind::NotFound => "Not Found",
73            AppErrorKind::Conflict => "Conflict",
74            AppErrorKind::Internal => "Internal Server Error",
75            AppErrorKind::FormationCycle => "Formation rejected: authority cycle",
76            AppErrorKind::FormationMultipleCoordinators => {
77                "Formation rejected: multiple coordinators"
78            }
79            AppErrorKind::FormationNoCoordinator => "Formation rejected: no coordinator",
80            AppErrorKind::FormationAuthorityNotNarrowing => {
81                "Formation rejected: authority does not narrow"
82            }
83        }
84    }
85}
86
87#[derive(Debug, Clone)]
88pub struct AppError {
89    pub kind: AppErrorKind,
90    pub detail: String,
91}
92
93impl AppError {
94    pub fn new(kind: AppErrorKind, detail: impl Into<String>) -> Self {
95        Self {
96            kind,
97            detail: detail.into(),
98        }
99    }
100
101    pub fn bad_request(detail: impl Into<String>) -> Self {
102        Self::new(AppErrorKind::BadRequest, detail)
103    }
104
105    pub fn unauthorized(detail: impl Into<String>) -> Self {
106        Self::new(AppErrorKind::Unauthorized, detail)
107    }
108
109    pub fn not_found(detail: impl Into<String>) -> Self {
110        Self::new(AppErrorKind::NotFound, detail)
111    }
112
113    pub fn internal(detail: impl Into<String>) -> Self {
114        Self::new(AppErrorKind::Internal, detail)
115    }
116}
117
118/// Wire shape of the problem document (RFC 9457 §3.1).
119#[derive(Debug, Serialize)]
120struct ProblemDetails<'a> {
121    #[serde(rename = "type")]
122    type_uri: &'a str,
123    title: &'a str,
124    status: u16,
125    detail: &'a str,
126}
127
128impl IntoResponse for AppError {
129    fn into_response(self) -> Response {
130        let status = self.kind.status();
131        let body = ProblemDetails {
132            type_uri: self.kind.type_uri(),
133            title: self.kind.title(),
134            status: status.as_u16(),
135            detail: &self.detail,
136        };
137        let mut resp = (status, Json(body)).into_response();
138        // RFC 9457 §3 — the media type is `application/problem+json`.
139        resp.headers_mut().insert(
140            axum::http::header::CONTENT_TYPE,
141            axum::http::HeaderValue::from_static("application/problem+json"),
142        );
143        resp
144    }
145}
146
147impl From<anyhow::Error> for AppError {
148    fn from(e: anyhow::Error) -> Self {
149        AppError::internal(format!("{e:#}"))
150    }
151}
152
153impl From<serde_json::Error> for AppError {
154    fn from(e: serde_json::Error) -> Self {
155        AppError::bad_request(format!("invalid json: {e}"))
156    }
157}