1use axum::http::StatusCode;
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use serde_json::json;
11
12#[derive(Debug, thiserror::Error)]
37pub enum ApiError {
38 #[error("unauthenticated")]
40 Unauthorized,
41 #[error("{message}")]
43 NotFound {
44 code: &'static str,
46 message: String,
48 },
49 #[error("{message}")]
51 BadRequest {
52 code: &'static str,
54 message: String,
56 },
57 #[error("{message}")]
63 TooManyRequests {
64 code: &'static str,
66 message: String,
68 },
69 #[error("{message}")]
71 Timeout {
72 message: String,
75 },
76 #[error("{0}")]
78 Internal(String),
79}
80
81impl ApiError {
82 pub fn unknown_index(id: &str) -> Self {
84 ApiError::NotFound {
85 code: "unknown_index",
86 message: format!("unknown index id '{id}'"),
87 }
88 }
89
90 pub fn unknown_node(id: &str) -> Self {
92 ApiError::NotFound {
93 code: "unknown_node",
94 message: format!("unknown node '{id}'"),
95 }
96 }
97
98 pub fn no_path(from: &str, to: &str) -> Self {
106 ApiError::NotFound {
107 code: "no_path",
108 message: format!("no typed path between '{from}' and '{to}'"),
109 }
110 }
111
112 pub fn missing_param(name: &str) -> Self {
114 ApiError::BadRequest {
115 code: "missing_param",
116 message: format!("missing required query parameter '{name}'"),
117 }
118 }
119
120 pub fn invalid_param(name: &str, reason: &str) -> Self {
122 ApiError::BadRequest {
123 code: "invalid_param",
124 message: format!("invalid query parameter '{name}': {reason}"),
125 }
126 }
127
128 pub fn invalid_body(reason: &str) -> Self {
130 ApiError::BadRequest {
131 code: "invalid_body",
132 message: format!("invalid request body: {reason}"),
133 }
134 }
135
136 pub fn invalid_repo_path(reason: &str) -> Self {
138 ApiError::BadRequest {
139 code: "invalid_repo_path",
140 message: format!("invalid repo_path: {reason}"),
141 }
142 }
143
144 pub fn invalid_repo_url(reason: &str) -> Self {
150 ApiError::BadRequest {
151 code: "invalid_repo_url",
152 message: format!("invalid repo_url: {reason}"),
153 }
154 }
155
156 pub fn index_quota(plan: &str, limit: usize, current: usize) -> Self {
161 ApiError::TooManyRequests {
162 code: "index_quota_exceeded",
163 message: format!(
164 "plan '{plan}' allows at most {limit} active indexes; \
165 this key already has {current}"
166 ),
167 }
168 }
169
170 pub fn query_quota(plan: &str, limit: u64, used: u64, month: &str) -> Self {
175 ApiError::TooManyRequests {
176 code: "query_quota_exceeded",
177 message: format!(
178 "plan '{plan}' allows {limit} queries per month; \
179 this key has used {used} in {month} (UTC)"
180 ),
181 }
182 }
183
184 pub fn file_quota(plan: &str, limit: usize) -> Self {
188 ApiError::TooManyRequests {
189 code: "file_quota_exceeded",
190 message: format!(
191 "the repository tree holds more than {limit} files, \
192 past plan '{plan}'s per-index file limit"
193 ),
194 }
195 }
196
197 pub fn index_concurrency(plan: &str, limit: usize) -> Self {
200 ApiError::TooManyRequests {
201 code: "index_concurrency_exceeded",
202 message: format!(
203 "plan '{plan}' allows {limit} index operation(s) at a time \
204 for one key; one is already running for this key"
205 ),
206 }
207 }
208
209 pub fn request_timeout(plan: &str, secs: u64) -> Self {
212 ApiError::Timeout {
213 message: format!(
214 "the query did not finish within {secs}s, \
215 the per-request limit of plan '{plan}'"
216 ),
217 }
218 }
219
220 pub fn internal(reason: impl Into<String>) -> Self {
222 ApiError::Internal(reason.into())
223 }
224
225 fn status(&self) -> StatusCode {
226 match self {
227 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
228 ApiError::NotFound { .. } => StatusCode::NOT_FOUND,
229 ApiError::BadRequest { .. } => StatusCode::BAD_REQUEST,
230 ApiError::TooManyRequests { .. } => StatusCode::TOO_MANY_REQUESTS,
231 ApiError::Timeout { .. } => StatusCode::GATEWAY_TIMEOUT,
232 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
233 }
234 }
235
236 pub fn code(&self) -> &'static str {
238 match self {
239 ApiError::Unauthorized => "unauthenticated",
240 ApiError::NotFound { code, .. }
241 | ApiError::BadRequest { code, .. }
242 | ApiError::TooManyRequests { code, .. } => code,
243 ApiError::Timeout { .. } => "request_timeout",
244 ApiError::Internal(_) => "internal",
245 }
246 }
247
248 pub const INTERNAL_CLIENT_MESSAGE: &'static str = "internal error";
256
257 pub fn body(&self) -> serde_json::Value {
267 let message = match self {
268 ApiError::Internal(detail) => {
269 eprintln!("error: internal failure: {detail}");
270 ApiError::INTERNAL_CLIENT_MESSAGE.to_string()
271 }
272 _ => self.to_string(),
273 };
274 json!({ "error": self.code(), "message": message })
275 }
276}
277
278impl IntoResponse for ApiError {
279 fn into_response(self) -> Response {
280 (self.status(), Json(self.body())).into_response()
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn status_matches_the_kind_of_failure() {
290 assert_eq!(ApiError::Unauthorized.status(), StatusCode::UNAUTHORIZED);
291 assert_eq!(ApiError::unknown_index("x").status(), StatusCode::NOT_FOUND);
292 assert_eq!(ApiError::unknown_node("x").status(), StatusCode::NOT_FOUND);
293 assert_eq!(ApiError::no_path("a", "b").status(), StatusCode::NOT_FOUND);
294 assert_eq!(
295 ApiError::missing_param("node").status(),
296 StatusCode::BAD_REQUEST
297 );
298 assert_eq!(
299 ApiError::invalid_param("limit", "not an integer").status(),
300 StatusCode::BAD_REQUEST
301 );
302 assert_eq!(
303 ApiError::invalid_body("bad json").status(),
304 StatusCode::BAD_REQUEST
305 );
306 assert_eq!(
307 ApiError::invalid_repo_path("missing").status(),
308 StatusCode::BAD_REQUEST
309 );
310 assert_eq!(
311 ApiError::invalid_repo_url("not https").status(),
312 StatusCode::BAD_REQUEST
313 );
314 assert_eq!(
315 ApiError::invalid_repo_url("not https").code(),
316 "invalid_repo_url"
317 );
318 assert_eq!(
319 ApiError::internal("boom").status(),
320 StatusCode::INTERNAL_SERVER_ERROR
321 );
322 assert_eq!(
323 ApiError::index_quota("hosted", 25, 25).status(),
324 StatusCode::TOO_MANY_REQUESTS
325 );
326 assert_eq!(
327 ApiError::request_timeout("hosted", 30).status(),
328 StatusCode::GATEWAY_TIMEOUT
329 );
330 }
331
332 #[test]
336 fn limit_codes_are_distinct_and_name_the_limit() {
337 let index = ApiError::index_quota("hosted", 25, 25);
338 assert_eq!(index.code(), "index_quota_exceeded");
339 assert!(index.to_string().contains("25"), "limit is named: {index}");
340 assert!(
341 index.to_string().contains("has 25"),
342 "current count is named: {index}"
343 );
344
345 let query = ApiError::query_quota("hosted", 10_000, 10_000, "2026-10");
346 assert_eq!(query.code(), "query_quota_exceeded");
347 assert!(query.to_string().contains("10000"), "{query}");
348 assert!(query.to_string().contains("2026-10"), "{query}");
349
350 let file = ApiError::file_quota("hosted", 50_000);
351 assert_eq!(file.code(), "file_quota_exceeded");
352 assert!(file.to_string().contains("50000"), "{file}");
353
354 let busy = ApiError::index_concurrency("hosted", 1);
355 assert_eq!(busy.code(), "index_concurrency_exceeded");
356
357 let timeout = ApiError::request_timeout("hosted", 30);
358 assert_eq!(timeout.code(), "request_timeout");
359 assert!(timeout.to_string().contains("30s"), "{timeout}");
360 }
361
362 #[test]
365 fn design_codes_are_all_reachable() {
366 for code in [
367 "unauthenticated",
368 "unknown_index",
369 "unknown_node",
370 "missing_param",
371 "invalid_param",
372 "invalid_body",
373 "invalid_repo_path",
374 "internal",
375 ] {
376 let err = match code {
377 "unauthenticated" => ApiError::Unauthorized,
378 "unknown_index" => ApiError::unknown_index("id"),
379 "unknown_node" => ApiError::unknown_node("id"),
380 "missing_param" => ApiError::missing_param("node"),
381 "invalid_param" => ApiError::invalid_param("limit", "out of range"),
382 "invalid_body" => ApiError::invalid_body("not json"),
383 "invalid_repo_path" => ApiError::invalid_repo_path("not a directory"),
384 _ => ApiError::internal("boom"),
385 };
386 assert_eq!(err.code(), code);
387 }
388 }
389
390 #[test]
391 fn body_is_flat_with_code_and_message() {
392 let body = ApiError::unknown_index("abc").body();
393 let obj = body.as_object().expect("body is an object");
394 assert_eq!(obj.len(), 2, "flat body has exactly error+message: {body}");
395 assert_eq!(obj["error"], "unknown_index");
396 assert!(obj["message"].as_str().unwrap().contains("abc"));
397 assert!(body.get("status").is_none());
399 }
400
401 #[test]
404 fn nonexistent_is_404_and_malformed_is_400() {
405 assert_eq!(ApiError::unknown_index("i").code(), "unknown_index");
406 assert_eq!(ApiError::unknown_node("n").code(), "unknown_node");
407 assert_eq!(ApiError::unknown_index("i").status(), StatusCode::NOT_FOUND);
408 assert_eq!(ApiError::missing_param("node").code(), "missing_param");
409 assert_eq!(
410 ApiError::missing_param("node").status(),
411 StatusCode::BAD_REQUEST
412 );
413 }
414
415 #[test]
418 fn message_carries_detail_not_internals() {
419 assert!(ApiError::missing_param("node").to_string().contains("node"));
420 assert!(ApiError::invalid_param("limit", "must be 1..=1000")
421 .to_string()
422 .contains("1..=1000"));
423 assert_eq!(ApiError::Unauthorized.to_string(), "unauthenticated");
424 }
425
426 #[test]
431 fn internal_body_is_generic_and_detail_free() {
432 let err = ApiError::internal("cannot open key store /srv/private/keys.db: disk I/O error");
433 let body = err.body();
434 assert_eq!(body["error"], "internal");
435 assert_eq!(body["message"], ApiError::INTERNAL_CLIENT_MESSAGE);
436 let rendered = body.to_string();
437 assert!(
438 !rendered.contains("/srv/private"),
439 "host path leaked to the client body: {rendered}"
440 );
441 assert!(
442 !rendered.contains("disk I/O"),
443 "store error text leaked to the client body: {rendered}"
444 );
445 assert!(err.to_string().contains("/srv/private/keys.db"));
448 let other = ApiError::unknown_index("abc").body();
450 assert!(other["message"].as_str().unwrap().contains("abc"));
451 }
452}