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 NotFound,
28 BadConfig(String),
29 Unprocessable {
32 message: String,
33 details: Option<serde_json::Value>,
34 },
35 Conflict(String),
37 QueueFull {
39 retry_after_secs: u64,
40 },
41 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 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 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 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 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}