Skip to main content

af_web/
error.rs

1//! JSON error envelope. Port of the reusable shape of `agent_core/api/errors.py`.
2//!
3//! Every handler returns `Result<T, ApiError>`; the error renders as a stable
4//! JSON body `{ "error": "...", "request_id": "..." }` with the right status.
5//! Carrying the request id back to the client is the Error-Localization rule on
6//! the wire — a user-reported failure is greppable by id.
7
8use af_context::RequestId;
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use serde_json::json;
13
14/// A handler error with an HTTP status and a user-safe message.
15#[derive(Debug, Clone)]
16pub struct ApiError {
17    /// HTTP status.
18    pub status: StatusCode,
19    /// Human-readable message.
20    pub message: String,
21    /// Transport request identity for tracing and idempotency.
22    pub request_id: Option<RequestId>,
23}
24
25impl ApiError {
26    /// An error envelope with an explicit status.
27    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
28        Self {
29            status,
30            message: message.into(),
31            request_id: None,
32        }
33    }
34
35    /// An error whose user-safe message resolves through the shared catalog.
36    pub fn localized(
37        status: StatusCode,
38        catalog: &af_i18n::I18n,
39        locale: &str,
40        key: &str,
41        args: &[(&str, &str)],
42    ) -> Self {
43        Self::new(status, catalog.t(locale, key, args))
44    }
45
46    /// HTTP 400.
47    pub fn bad_request(message: impl Into<String>) -> Self {
48        Self::new(StatusCode::BAD_REQUEST, message)
49    }
50    /// HTTP 404.
51    pub fn not_found(message: impl Into<String>) -> Self {
52        Self::new(StatusCode::NOT_FOUND, message)
53    }
54    /// HTTP 500.
55    pub fn internal(message: impl Into<String>) -> Self {
56        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
57    }
58
59    /// Attach the request id (the latency/request-id middleware sets one).
60    pub fn with_request_id(mut self, id: RequestId) -> Self {
61        self.request_id = Some(id);
62        self
63    }
64}
65
66impl IntoResponse for ApiError {
67    fn into_response(self) -> Response {
68        let body = json!({
69            "error": self.message,
70            "request_id": self.request_id,
71        });
72        (self.status, Json(body)).into_response()
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn localized_error_uses_catalog_fallback() {
82        let mut catalog = af_i18n::I18n::new("en").unwrap();
83        catalog
84            .load_locale("en", serde_json::json!({"error": "Unavailable"}))
85            .unwrap();
86        assert_eq!(
87            ApiError::localized(
88                StatusCode::SERVICE_UNAVAILABLE,
89                &catalog,
90                "fr",
91                "error",
92                &[]
93            )
94            .message,
95            "Unavailable"
96        );
97    }
98}