Skip to main content

distri_types/
api_error.rs

1//! `ApiError` — the single error type that every distri service returns.
2//!
3//! Variants map cleanly to HTTP status codes. Routes return
4//! `Result<HttpResponse, ApiError>`; the `ResponseError` impl lives in
5//! `distri-server` (where the actix dependency lives) and renders every
6//! variant as `{"error": "<message>"}` JSON with the appropriate status.
7//!
8//! Store calls return `anyhow::Result<T>`; the `#[from] anyhow::Error`
9//! conversion lets services `?` straight through, surfacing unexpected
10//! errors as `ApiError::Internal` (logged + 500). Business decisions
11//! (validation failures, "not found", "this is forbidden") explicitly
12//! return the typed variant — no string-parsing at the boundary.
13
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum ApiError {
18    /// Caller's input is malformed or violates a documented rule.
19    /// Maps to HTTP 400.
20    #[error("{0}")]
21    BadRequest(String),
22
23    /// No authenticated session, or the session is invalid/expired.
24    /// Maps to HTTP 401.
25    #[error("{0}")]
26    Unauthorized(String),
27
28    /// Authenticated, but the operation is not permitted for this caller
29    /// (e.g. mutating an `is_system=true` row). Maps to HTTP 403.
30    #[error("{0}")]
31    Forbidden(String),
32
33    /// Entity does not exist. Maps to HTTP 404.
34    #[error("{0}")]
35    NotFound(String),
36
37    /// Operation would violate a uniqueness constraint or a state
38    /// invariant (e.g. duplicate name in workspace). Maps to HTTP 409.
39    #[error("{0}")]
40    Conflict(String),
41
42    /// Request shape is valid but its content fails domain validation
43    /// (e.g. a referenced credential's material is wrong for this flow).
44    /// Maps to HTTP 422.
45    #[error("{0}")]
46    Unprocessable(String),
47
48    /// Backing service unavailable (store not wired, OAuth not configured).
49    /// Maps to HTTP 503.
50    #[error("{0}")]
51    ServiceUnavailable(String),
52
53    /// Upstream service (MCP server, OAuth provider, downstream HTTP API)
54    /// returned an error we want to surface to the caller verbatim. Maps
55    /// to HTTP 502. Use this — NOT `Internal` — when the failure is the
56    /// remote system's behaviour, not our bug: the UI needs the original
57    /// message to tell the user what to fix.
58    #[error("{0}")]
59    BadGateway(String),
60
61    /// Wraps an unexpected error (DB, IO, serde, anything else). Logged at
62    /// the route boundary; surfaced as a generic HTTP 500 to the client so
63    /// internal details don't leak.
64    #[error(transparent)]
65    Internal(#[from] anyhow::Error),
66}
67
68impl ApiError {
69    /// HTTP status this variant maps to.
70    pub fn status(&self) -> u16 {
71        match self {
72            Self::BadRequest(_) => 400,
73            Self::Unauthorized(_) => 401,
74            Self::Forbidden(_) => 403,
75            Self::NotFound(_) => 404,
76            Self::Conflict(_) => 409,
77            Self::Unprocessable(_) => 422,
78            Self::ServiceUnavailable(_) => 503,
79            Self::BadGateway(_) => 502,
80            Self::Internal(_) => 500,
81        }
82    }
83
84    /// Client-safe message. `Internal` returns a generic string — the
85    /// actual error is logged server-side via the `ResponseError` impl
86    /// instead of being leaked to the client.
87    pub fn message(&self) -> String {
88        match self {
89            Self::Internal(_) => "internal server error".to_string(),
90            other => other.to_string(),
91        }
92    }
93}
94
95// ── Constructors — terse call sites: `ApiError::not_found("...")` etc.
96impl ApiError {
97    pub fn bad_request(msg: impl Into<String>) -> Self {
98        Self::BadRequest(msg.into())
99    }
100    pub fn unauthorized(msg: impl Into<String>) -> Self {
101        Self::Unauthorized(msg.into())
102    }
103    pub fn forbidden(msg: impl Into<String>) -> Self {
104        Self::Forbidden(msg.into())
105    }
106    pub fn not_found(msg: impl Into<String>) -> Self {
107        Self::NotFound(msg.into())
108    }
109    pub fn conflict(msg: impl Into<String>) -> Self {
110        Self::Conflict(msg.into())
111    }
112    pub fn unprocessable(msg: impl Into<String>) -> Self {
113        Self::Unprocessable(msg.into())
114    }
115    pub fn service_unavailable(msg: impl Into<String>) -> Self {
116        Self::ServiceUnavailable(msg.into())
117    }
118    pub fn bad_gateway(msg: impl Into<String>) -> Self {
119        Self::BadGateway(msg.into())
120    }
121}
122
123pub type ApiResult<T> = Result<T, ApiError>;
124
125// ── Actix integration (feature = "actix") ───────────────────────────────
126//
127// Putting the impl here (vs. in distri-server) sidesteps Rust's orphan
128// rule: `ResponseError` and `ApiError` need to be in the same crate. Off
129// by default; distri-server / distri-cloud opt in via the `actix` feature.
130#[cfg(feature = "actix")]
131impl actix_web::ResponseError for ApiError {
132    fn status_code(&self) -> actix_web::http::StatusCode {
133        actix_web::http::StatusCode::from_u16(self.status())
134            .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)
135    }
136
137    fn error_response(&self) -> actix_web::HttpResponse {
138        if let ApiError::Internal(e) = self {
139            tracing::error!("internal error: {:#}", e);
140        }
141        actix_web::HttpResponse::build(self.status_code())
142            .json(serde_json::json!({ "error": self.message() }))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn status_codes() {
152        assert_eq!(ApiError::bad_request("x").status(), 400);
153        assert_eq!(ApiError::unauthorized("x").status(), 401);
154        assert_eq!(ApiError::forbidden("x").status(), 403);
155        assert_eq!(ApiError::not_found("x").status(), 404);
156        assert_eq!(ApiError::conflict("x").status(), 409);
157        assert_eq!(ApiError::unprocessable("x").status(), 422);
158        assert_eq!(ApiError::service_unavailable("x").status(), 503);
159        assert_eq!(ApiError::Internal(anyhow::anyhow!("oops")).status(), 500);
160    }
161
162    #[test]
163    fn internal_message_is_generic() {
164        let e = ApiError::Internal(anyhow::anyhow!("db failed: ..."));
165        assert_eq!(e.message(), "internal server error");
166    }
167
168    #[test]
169    fn anyhow_conversion_is_internal() {
170        let e: ApiError = anyhow::anyhow!("any error").into();
171        assert!(matches!(e, ApiError::Internal(_)));
172    }
173}