Skip to main content

api_gateway/
error.rs

1use axum::{
2    Json,
3    http::StatusCode,
4    response::{IntoResponse, Response},
5};
6use serde::Serialize;
7use thiserror::Error;
8
9/// Type alias for error response tuple (without details).
10/// We keep `details` separately as `Option<String>` to avoid lifetime issues.
11type ErrorResponseTuple<'a> = (StatusCode, &'static str, &'a str);
12
13#[derive(Debug, Error)]
14pub enum AppError {
15    #[error("{0}")]
16    BadRequest(String),
17    #[error("{0}")]
18    Unauthorized(String),
19    #[error("{0}")]
20    Forbidden(String),
21    #[error("{0}")]
22    NotFound(String),
23    #[error("{0}")]
24    Conflict(String),
25    #[error("rate limited")]
26    TooManyRequests,
27    #[error("internal error")]
28    Internal(#[source] anyhow::Error),
29}
30
31#[derive(Serialize)]
32struct ErrorBody<'a> {
33    code: &'a str,
34    message: &'a str,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    request_id: Option<&'a str>,
37    #[cfg(feature = "debug-errors")]
38    #[serde(skip_serializing_if = "Option::is_none")]
39    details: Option<&'a str>,
40}
41
42impl IntoResponse for AppError {
43    fn into_response(self) -> Response {
44        use AppError::{
45            BadRequest, Conflict, Forbidden, Internal, NotFound, TooManyRequests, Unauthorized,
46        };
47
48        // Extract request_id from current span context if available
49        // Note: In real handlers we will get request_id from extensions
50        let request_id = "unknown";
51
52        // Keep details as owned String to avoid dangling references
53        #[cfg(feature = "debug-errors")]
54        let mut dbg_details: Option<String> = None;
55
56        // Map AppError to tuple (status, code, safe_msg)
57        let (status, code, safe_msg): ErrorResponseTuple = match &self {
58            BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m.as_str()),
59            Unauthorized(m) => (StatusCode::UNAUTHORIZED, "unauthorized", m.as_str()),
60            Forbidden(m) => (StatusCode::FORBIDDEN, "forbidden", m.as_str()),
61            NotFound(m) => (StatusCode::NOT_FOUND, "not_found", m.as_str()),
62            Conflict(m) => (StatusCode::CONFLICT, "conflict", m.as_str()),
63            TooManyRequests => (
64                StatusCode::TOO_MANY_REQUESTS,
65                "rate_limited",
66                "rate limited",
67            ),
68            #[cfg_attr(not(feature = "debug-errors"), allow(unused_variables))]
69            Internal(err) => {
70                #[cfg(feature = "debug-errors")]
71                {
72                    // Save error details as String, later exposed as Option<&str>
73                    dbg_details = Some(err.to_string());
74                }
75                (
76                    StatusCode::INTERNAL_SERVER_ERROR,
77                    "internal_error",
78                    "internal error",
79                )
80            }
81        };
82
83        // Log error with appropriate severity
84        match &self {
85            Internal(err) => tracing::error!(
86                request_id = %request_id,
87                error = %err,
88                status = status.as_u16(),
89                "request failed"
90            ),
91            other => tracing::warn!(
92                request_id = %request_id,
93                error = %other,
94                status = status.as_u16(),
95                "request failed"
96            ),
97        }
98
99        // Build JSON response body
100        let body = ErrorBody {
101            code,
102            message: safe_msg,
103            request_id: Some(request_id),
104            #[cfg(feature = "debug-errors")]
105            details: dbg_details.as_deref(),
106        };
107
108        (status, Json(body)).into_response()
109    }
110}