Skip to main content

prospero_api/
error.rs

1//! Mapping `CoreError` to HTTP responses. Typed errors become precise status
2//! codes (404/409/503) — never an opaque 500 for an expected condition.
3
4use axum::Json;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use prospero_core::CoreError;
8use serde::Serialize;
9
10/// An API error that renders as `{ "error": "...", "kind": "..." }`.
11pub enum ApiError {
12    /// A typed core error mapped to a precise status code.
13    Core(CoreError),
14    /// The operation exists but the active fleet backend does not support it
15    /// (e.g. workspace-registry ops under k8s) → 405. (#76)
16    MethodNotAllowed(String),
17    /// Missing, malformed, unknown, expired or revoked credential → 401.
18    Unauthorized,
19    /// Authenticated but not permitted → 403.
20    Forbidden(String),
21    /// Resource absent (e.g. `POST /api/session` with auth disabled) → 404.
22    NotFound(String),
23    /// The request itself was malformed — today, an unusable
24    /// `X-Prospero-On-Behalf-Of` header (#251) → 400.
25    BadRequest(String),
26}
27
28impl ApiError {
29    /// The requested operation is real but not served by the active backend —
30    /// e.g. the workspace registry/config plane under k8s, where workspaces are
31    /// `CalibanTask`/namespace-driven rather than a prospero registry. (#76)
32    #[must_use]
33    pub fn unsupported_on_backend() -> Self {
34        ApiError::MethodNotAllowed(
35            "not supported by the active fleet backend (k8s workspaces are \
36             CalibanTask/namespace-driven, not a prospero registry)"
37                .to_string(),
38        )
39    }
40}
41
42#[derive(Serialize)]
43struct ErrorBody {
44    error: String,
45    kind: &'static str,
46}
47
48impl From<CoreError> for ApiError {
49    fn from(e: CoreError) -> Self {
50        ApiError::Core(e)
51    }
52}
53
54impl IntoResponse for ApiError {
55    fn into_response(self) -> Response {
56        let www_authenticate = matches!(self, ApiError::Unauthorized);
57        let (status, kind, error) = match self {
58            ApiError::MethodNotAllowed(msg) => {
59                (StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed", msg)
60            }
61            ApiError::Unauthorized => (
62                StatusCode::UNAUTHORIZED,
63                "unauthorized",
64                "unauthorized".to_string(),
65            ),
66            ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg),
67            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg),
68            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg),
69            ApiError::Core(e) => {
70                let (status, kind) = match &e {
71                    CoreError::AgentNotFound(_)
72                    | CoreError::WorkspaceNotFound(_)
73                    | CoreError::AutomationNotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
74                    CoreError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
75                    CoreError::InvalidState { .. } => (StatusCode::CONFLICT, "invalid_state"),
76                    CoreError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
77                    CoreError::ProviderMisconfigured(_) => {
78                        (StatusCode::BAD_REQUEST, "provider_misconfigured")
79                    }
80                    CoreError::InvalidConfig(_) => (StatusCode::BAD_REQUEST, "invalid_config"),
81                    CoreError::CalibandUnreachable { .. }
82                    | CoreError::Discovery(_)
83                    | CoreError::Fleet(_) => (StatusCode::SERVICE_UNAVAILABLE, "unreachable"),
84                    CoreError::Protocol(_) => (StatusCode::BAD_GATEWAY, "protocol"),
85                    CoreError::Store(_)
86                    | CoreError::SeqConflict
87                    | CoreError::Io(_)
88                    | CoreError::Json(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
89                };
90                (status, kind, e.to_string())
91            }
92        };
93        let body = ErrorBody { error, kind };
94        let mut response = (status, Json(body)).into_response();
95        if www_authenticate {
96            response.headers_mut().insert(
97                axum::http::header::WWW_AUTHENTICATE,
98                axum::http::HeaderValue::from_static("Bearer realm=\"prospero\""),
99            );
100        }
101        response
102    }
103}