faucet_cli/serve/
error.rs1use axum::Json;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Serialize;
8
9#[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#[derive(Debug)]
25pub enum ServeError {
26 Unauthorized,
27 Forbidden(String),
29 NotFound,
30 BadConfig(String),
31 Unprocessable {
34 message: String,
35 details: Option<serde_json::Value>,
36 },
37 Conflict(String),
39 QueueFull {
41 retry_after_secs: u64,
42 },
43 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 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 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 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 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}