Skip to main content

faucet_cli/serve/
error.rs

1//! HTTP-facing error type. Every fallible serve handler returns `ServeError`,
2//! which renders to a JSON `ApiError` body with the right status code.
3
4use axum::Json;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Serialize;
8
9/// JSON error envelope: `{ "error": { "code": "...", "message": "..." } }`.
10#[derive(Debug, Serialize)]
11pub struct ApiError {
12    pub error: ApiErrorBody,
13}
14
15#[derive(Debug, Serialize)]
16pub struct ApiErrorBody {
17    pub code: String,
18    pub message: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub details: Option<serde_json::Value>,
21}
22
23/// All error outcomes a serve handler can produce.
24#[derive(Debug)]
25pub enum ServeError {
26    Unauthorized,
27    /// 403 — authenticated but the principal's role lacks the required permission.
28    Forbidden(String),
29    NotFound,
30    BadConfig(String),
31    /// 422 — expand/validation failure or a failed `doctor_first` preflight;
32    /// `details` carries the doctor report when present.
33    Unprocessable {
34        message: String,
35        details: Option<serde_json::Value>,
36    },
37    /// 409 — delete on a running run, or idempotency key reused with a new payload.
38    Conflict(String),
39    /// 429 — the run queue is full.
40    QueueFull {
41        retry_after_secs: u64,
42    },
43    /// 503 — a required dependency is temporarily unavailable (e.g. idempotency
44    /// can't be honored while the run-history backend is degraded).
45    Unavailable(String),
46    Internal(String),
47}
48
49impl ServeError {
50    pub fn status(&self) -> StatusCode {
51        match self {
52            ServeError::Unauthorized => StatusCode::UNAUTHORIZED,
53            ServeError::Forbidden(_) => StatusCode::FORBIDDEN,
54            ServeError::NotFound => StatusCode::NOT_FOUND,
55            ServeError::BadConfig(_) => StatusCode::BAD_REQUEST,
56            ServeError::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
57            ServeError::Conflict(_) => StatusCode::CONFLICT,
58            ServeError::QueueFull { .. } => StatusCode::TOO_MANY_REQUESTS,
59            ServeError::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
60            ServeError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
61        }
62    }
63
64    fn code(&self) -> &'static str {
65        match self {
66            ServeError::Unauthorized => "unauthorized",
67            ServeError::Forbidden(_) => "forbidden",
68            ServeError::NotFound => "not_found",
69            ServeError::BadConfig(_) => "bad_config",
70            ServeError::Unprocessable { .. } => "unprocessable",
71            ServeError::Conflict(_) => "conflict",
72            ServeError::QueueFull { .. } => "queue_full",
73            ServeError::Unavailable(_) => "unavailable",
74            ServeError::Internal(_) => "internal",
75        }
76    }
77
78    fn message(&self) -> String {
79        match self {
80            ServeError::Unauthorized => "missing or invalid bearer token".into(),
81            ServeError::Forbidden(m) => m.clone(),
82            ServeError::NotFound => "not found".into(),
83            ServeError::BadConfig(m) => m.clone(),
84            ServeError::Unprocessable { message, .. } => message.clone(),
85            ServeError::Conflict(m) => m.clone(),
86            ServeError::QueueFull { .. } => "run queue is full; retry later".into(),
87            ServeError::Unavailable(m) => m.clone(),
88            ServeError::Internal(m) => m.clone(),
89        }
90    }
91
92    fn details(&self) -> Option<serde_json::Value> {
93        match self {
94            ServeError::Unprocessable { details, .. } => details.clone(),
95            _ => None,
96        }
97    }
98
99    pub fn api_error(&self) -> ApiError {
100        // Scrub any resolved secret that reached the message or details.
101        let message = crate::secrets::registry::redact(&self.message()).into_owned();
102        let details = self.details().map(|d| {
103            let scrubbed = crate::secrets::registry::redact(&d.to_string()).into_owned();
104            serde_json::from_str(&scrubbed)
105                .unwrap_or_else(|_| serde_json::json!({ "redacted": true }))
106        });
107        ApiError {
108            error: ApiErrorBody {
109                code: self.code().to_string(),
110                message,
111                details,
112            },
113        }
114    }
115}
116
117impl IntoResponse for ServeError {
118    fn into_response(self) -> Response {
119        let status = self.status();
120        let mut resp = (status, Json(self.api_error())).into_response();
121        if let ServeError::QueueFull { retry_after_secs } = &self
122            && let Ok(v) = axum::http::HeaderValue::from_str(&retry_after_secs.to_string())
123        {
124            resp.headers_mut()
125                .insert(axum::http::header::RETRY_AFTER, v);
126        }
127        resp
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use axum::http::StatusCode;
135
136    #[test]
137    fn maps_variants_to_status_codes() {
138        assert_eq!(ServeError::Unauthorized.status(), StatusCode::UNAUTHORIZED);
139        assert_eq!(ServeError::NotFound.status(), StatusCode::NOT_FOUND);
140        assert_eq!(
141            ServeError::BadConfig("nope".into()).status(),
142            StatusCode::BAD_REQUEST
143        );
144        assert_eq!(
145            ServeError::Internal("boom".into()).status(),
146            StatusCode::INTERNAL_SERVER_ERROR
147        );
148    }
149
150    #[test]
151    fn body_carries_code_and_message() {
152        let body = ServeError::NotFound.api_error();
153        assert_eq!(body.error.code, "not_found");
154        assert!(!body.error.message.is_empty());
155
156        // Fixed-message variant carries the expected static text.
157        let body = ServeError::Unauthorized.api_error();
158        assert_eq!(body.error.code, "unauthorized");
159        assert_eq!(body.error.message, "missing or invalid bearer token");
160    }
161
162    #[test]
163    fn dynamic_message_variants_round_trip_to_body() {
164        // BadConfig / Internal carry caller-supplied text — confirm it reaches
165        // the body intact (the redaction pass leaves non-secret text unchanged).
166        let body = ServeError::BadConfig("bad thing".into()).api_error();
167        assert_eq!(body.error.code, "bad_config");
168        assert_eq!(body.error.message, "bad thing");
169
170        let body = ServeError::Internal("boom".into()).api_error();
171        assert_eq!(body.error.code, "internal");
172        assert_eq!(body.error.message, "boom");
173    }
174
175    #[test]
176    fn new_variants_map_to_status_codes() {
177        assert_eq!(
178            ServeError::Unprocessable {
179                message: "x".into(),
180                details: None
181            }
182            .status(),
183            StatusCode::UNPROCESSABLE_ENTITY
184        );
185        assert_eq!(
186            ServeError::Conflict("x".into()).status(),
187            StatusCode::CONFLICT
188        );
189        assert_eq!(
190            ServeError::QueueFull {
191                retry_after_secs: 5
192            }
193            .status(),
194            StatusCode::TOO_MANY_REQUESTS
195        );
196    }
197
198    #[test]
199    fn unprocessable_carries_details() {
200        let body = ServeError::Unprocessable {
201            message: "doctor failed".into(),
202            details: Some(serde_json::json!({"invocations": []})),
203        }
204        .api_error();
205        assert_eq!(body.error.code, "unprocessable");
206        assert!(body.error.details.is_some());
207    }
208
209    #[test]
210    fn phase1_variants_omit_details_on_the_wire() {
211        // details uses skip_serializing_if=Option::is_none, so non-422 variants
212        // must not emit a "details" key (backward-compatible wire shape).
213        let body = ServeError::NotFound.api_error();
214        let v = serde_json::to_value(&body).unwrap();
215        assert!(v["error"].get("details").is_none());
216    }
217
218    #[tokio::test]
219    async fn queue_full_sets_retry_after_header() {
220        let resp = ServeError::QueueFull {
221            retry_after_secs: 7,
222        }
223        .into_response();
224        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
225        assert_eq!(
226            resp.headers().get(axum::http::header::RETRY_AFTER).unwrap(),
227            "7"
228        );
229    }
230}