Skip to main content

codetopo_server/
error.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! JSON error type shared by every handler and middleware.
6
7use axum::http::StatusCode;
8use axum::response::{IntoResponse, Response};
9use axum::Json;
10use serde_json::json;
11
12/// Every failure the API can report, with the JSON body clients receive.
13///
14/// ## Deviation from the design
15///
16/// Phase 2 design §7 specified a nested body carrying the status twice
17/// (`{"error": {"code", "message", "status"}}`). The body here is flat —
18/// `{"error": "<code>", "message": "<text>"}` — and the status is only in the
19/// HTTP status line. Rationale: the status is already authoritative on the wire
20/// and repeating it in the body is a second source of truth that can disagree
21/// with the status line. Flattening also means an agent parses one object with
22/// no branch before it can read the machine code.
23///
24/// `error` is a stable machine code drawn from a closed set
25/// ([`ApiError::code`]) and is what a client should branch on. The codes are the
26/// ones §7 enumerates; `message` is prose for humans and is not guaranteed to
27/// be stable across releases.
28///
29/// ## Why `NotFound` and `BadRequest` carry their own code
30///
31/// Both map to a single HTTP status, so the status alone cannot tell an agent
32/// what went wrong: `404` covers both "no such index" and "no such node", and
33/// `400` covers a missing param, an unparseable param, and a bad body. The
34/// status says *what kind* of failure; the code says *which*, which is what
35/// lets a client retry sensibly without matching on prose.
36#[derive(Debug, thiserror::Error)]
37pub enum ApiError {
38    /// No `Authorization` header, wrong scheme, unknown key, or wrong secret.
39    #[error("unauthenticated")]
40    Unauthorized,
41    /// The request was well-formed but names something that does not exist.
42    #[error("{message}")]
43    NotFound {
44        /// Stable machine code, e.g. `unknown_index`.
45        code: &'static str,
46        /// Human-readable detail.
47        message: String,
48    },
49    /// The request itself is malformed (missing field, bad value).
50    #[error("{message}")]
51    BadRequest {
52        /// Stable machine code, e.g. `missing_param`.
53        code: &'static str,
54        /// Human-readable detail.
55        message: String,
56    },
57    /// The request was authenticated and well-formed, but a limit of
58    /// the caller's plan refused it (paid-path S3). A distinct status
59    /// — not a `400` with a quota-flavoured code — because `429` is
60    /// the one answer a client can act on without parsing prose: stop,
61    /// wait for the window the message names, retry then.
62    #[error("{message}")]
63    TooManyRequests {
64        /// Stable machine code, e.g. `index_quota_exceeded`.
65        code: &'static str,
66        /// Human-readable detail.
67        message: String,
68    },
69    /// A query outlived its plan's per-request time limit.
70    #[error("{message}")]
71    Timeout {
72        /// Human-readable detail. One timeout shape exists, so the
73        /// code (`request_timeout`) is fixed rather than carried.
74        message: String,
75    },
76    /// Storage, indexing, or an unexpected internal failure.
77    #[error("{0}")]
78    Internal(String),
79}
80
81impl ApiError {
82    /// 404 `unknown_index`: `{id}` does not name a database in the data dir.
83    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    /// 404 `unknown_node`: the index loaded, but the node is not in the graph.
91    pub fn unknown_node(id: &str) -> Self {
92        ApiError::NotFound {
93            code: "unknown_node",
94            message: format!("unknown node '{id}'"),
95        }
96    }
97
98    /// 404 `no_path`: no typed path connects the two nodes.
99    ///
100    /// Not in the §7 table, which does not cover a well-formed `path` query
101    /// whose answer is "these two symbols are unrelated". The CLI rejects the
102    /// same case rather than returning an empty path, so `404` is preserved and
103    /// given its own code instead of being folded into `unknown_index` or
104    /// reported as an empty `200`.
105    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    /// 400 `missing_param`: a required query parameter is absent or empty.
113    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    /// 400 `invalid_param`: a query parameter is present but unusable.
121    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    /// 400 `invalid_body`: the request body is not the expected JSON shape.
129    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    /// 400 `invalid_repo_path`: `repo_path` is missing or is not a directory.
137    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    /// 400 `invalid_repo_url`: `repo_url` is unparseable, not a public
145    /// HTTPS URL, or its clone failed (paid-path S2). The sibling of
146    /// `invalid_repo_path` for the hosted ingest mode: one code per
147    /// ingest field, so a client can branch on which source was refused
148    /// without matching on prose.
149    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    /// 429 `index_quota_exceeded`: the key already holds its plan's
157    /// active-index limit. Names the limit and the current count, so
158    /// the caller knows exactly what must be deleted or expire before
159    /// another creation can succeed.
160    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    /// 429 `query_quota_exceeded`: the key has used its plan's
171    /// monthly query allowance. The month is the UTC calendar month
172    /// (see [`crate::limits`]); naming it tells the caller when the
173    /// quota resets without a second request to ask.
174    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    /// 429 `file_quota_exceeded`: the tree holds more files than the
185    /// plan's per-index cap. The count is a lower bound (the walk
186    /// stops just past the limit), so the message says "more than".
187    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    /// 429 `index_concurrency_exceeded`: the key already runs its
198    /// plan's allowance of concurrent index operations.
199    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    /// 504 `request_timeout`: the query did not finish within the
210    /// plan's per-request limit.
211    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    /// 500 from any internal failure, carrying `reason`.
221    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    /// The stable machine code clients branch on.
237    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    /// The only message a client ever sees for an internal failure.
249    ///
250    /// The `Internal` variant's payload is operator detail — host
251    /// filesystem paths, rusqlite error text, store file names — that
252    /// helps the person running the server and must never reach a
253    /// client (fleet release gate F-1). [`ApiError::body`] logs the
254    /// payload server-side and renders this generic text instead.
255    pub const INTERNAL_CLIENT_MESSAGE: &'static str = "internal error";
256
257    /// Body as a JSON value; exposed for tests and for asserting shape.
258    ///
259    /// This is the single render point for client responses
260    /// ([`IntoResponse`] goes through it), so the `Internal` redaction
261    /// lives here rather than at the variant's many construction sites:
262    /// the detail is written to the server's stderr and the client
263    /// receives [`ApiError::INTERNAL_CLIENT_MESSAGE`]. Every other
264    /// variant's message is prose about the caller's own request and
265    /// renders as before.
266    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    /// The S3 limit refusals: each plan limit has its own code, so a
333    /// client can tell "delete an index" from "wait for next month"
334    /// from "shrink the repository" without matching on prose.
335    #[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    /// Every code §7 enumerates must be reachable, so the closed set clients
363    /// branch on is actually the set the server can emit.
364    #[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        // The status is not duplicated into the body (see module docs).
398        assert!(body.get("status").is_none());
399    }
400
401    /// §7's 404-vs-400 split: naming a nonexistent thing is 404, being
402    /// malformed is 400, and the code keeps them apart within a status.
403    #[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    /// The rendered message must not leak a secret, and must include the detail
416    /// the client needs to fix its own request.
417    #[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    /// F-1: the client body for an internal failure is the generic
427    /// message — never the variant's payload, which carries host paths
428    /// and store error text. The code stays `internal`, the status
429    /// stays 500, and no other variant's rendering changes.
430    #[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        // The detail survives in the operator-facing Display — only
446        // the client render is redacted.
447        assert!(err.to_string().contains("/srv/private/keys.db"));
448        // A non-internal body is untouched by the redaction.
449        let other = ApiError::unknown_index("abc").body();
450        assert!(other["message"].as_str().unwrap().contains("abc"));
451    }
452}