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