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