Skip to main content

nidus_http/
error.rs

1//! HTTP error helpers.
2
3use std::{
4    future::Future,
5    pin::Pin,
6    task::{Context, Poll},
7};
8
9use axum::{Json, body::Body, body::to_bytes, extract::Request, response::IntoResponse};
10use http::StatusCode;
11use serde::{Deserialize, Serialize};
12use tower::{Layer, Service};
13
14use crate::context::RequestContext;
15
16/// HTTP error response with stable client-facing JSON shape.
17///
18/// `HttpError` constructors produce the legacy/simple body
19/// `{ "error": { "code": "...", "message": "..." } }`. When
20/// [`ErrorEnvelopeLayer`] is installed, that body is wrapped into the
21/// production envelope with status, timestamp, path, and request ID fields.
22///
23/// Client-error constructors such as [`Self::bad_request`] and
24/// [`Self::not_found`] expose the message you provide. Use
25/// [`Self::internal_server_error`] for 500 responses that must not leak
26/// implementation details; the production envelope also masks any 5xx response
27/// message to `"internal server error"` and clears `details`.
28#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
29#[error("{message}")]
30pub struct HttpError {
31    status: StatusCode,
32    code: &'static str,
33    message: String,
34}
35
36impl HttpError {
37    /// Creates an HTTP error with an explicit status, code, and message.
38    ///
39    /// For non-5xx statuses, the message is client-facing. For 5xx statuses,
40    /// prefer [`Self::internal_server_error`] unless the response is guaranteed
41    /// to be safe; [`ErrorEnvelopeLayer`] will still mask 5xx messages.
42    pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
43        Self {
44            status,
45            code,
46            message: message.into(),
47        }
48    }
49
50    /// Creates a 400 bad request error with a client-facing message.
51    pub fn bad_request(message: impl Into<String>) -> Self {
52        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
53    }
54
55    /// Creates a 401 unauthorized error with a client-facing message.
56    pub fn unauthorized(message: impl Into<String>) -> Self {
57        Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
58    }
59
60    /// Creates a 403 forbidden error with a client-facing message.
61    pub fn forbidden(message: impl Into<String>) -> Self {
62        Self::new(StatusCode::FORBIDDEN, "forbidden", message)
63    }
64
65    /// Creates a 404 not found error with a client-facing message.
66    pub fn not_found(message: impl Into<String>) -> Self {
67        Self::new(StatusCode::NOT_FOUND, "not_found", message)
68    }
69
70    /// Creates a 409 conflict error with a client-facing message.
71    pub fn conflict(message: impl Into<String>) -> Self {
72        Self::new(StatusCode::CONFLICT, "conflict", message)
73    }
74
75    /// Creates a 429 too many requests error with a client-facing message.
76    pub fn too_many_requests(message: impl Into<String>) -> Self {
77        Self::new(StatusCode::TOO_MANY_REQUESTS, "too_many_requests", message)
78    }
79
80    /// Creates a 422 unprocessable entity error with a client-facing message.
81    pub fn unprocessable_entity(message: impl Into<String>) -> Self {
82        Self::new(
83            StatusCode::UNPROCESSABLE_ENTITY,
84            "unprocessable_entity",
85            message,
86        )
87    }
88
89    /// Creates a sanitized 500 internal server error.
90    ///
91    /// The message is always `"internal server error"` so callers do not
92    /// accidentally expose database errors, stack traces, or upstream payloads.
93    pub fn internal_server_error() -> Self {
94        Self::new(
95            StatusCode::INTERNAL_SERVER_ERROR,
96            "internal_server_error",
97            "internal server error",
98        )
99    }
100
101    /// Returns the HTTP status code.
102    pub fn status(&self) -> StatusCode {
103        self.status
104    }
105
106    /// Returns the stable machine-readable error code.
107    pub fn code(&self) -> &'static str {
108        self.code
109    }
110
111    /// Returns the client-facing error message.
112    pub fn message(&self) -> &str {
113        &self.message
114    }
115}
116
117impl IntoResponse for HttpError {
118    fn into_response(self) -> axum::response::Response {
119        let status = self.status;
120        let code = self.code;
121        let message = self.message;
122
123        if status.is_server_error() {
124            tracing::error!(
125                http.status = status.as_u16(),
126                error.code = code,
127                error.message = %message,
128                "http error response"
129            );
130        } else {
131            tracing::warn!(
132                http.status = status.as_u16(),
133                error.code = code,
134                error.message = %message,
135                "http error response"
136            );
137        }
138
139        let body = Json(ErrorBody {
140            error: ErrorDetails { code, message },
141        });
142        (status, body).into_response()
143    }
144}
145
146/// Default unmatched-route handler for Nidus HTTP applications.
147///
148/// Install this with [`axum::Router::fallback`] when missing routes should
149/// produce the same Nidus JSON error shape as handler-created 404 responses.
150/// [`crate::middleware::ApiDefaults::production`] installs it by default.
151pub async fn not_found_fallback() -> HttpError {
152    HttpError::not_found("route not found")
153}
154
155#[derive(Debug, Serialize)]
156struct ErrorBody {
157    error: ErrorDetails,
158}
159
160#[derive(Debug, Serialize)]
161struct ErrorDetails {
162    code: &'static str,
163    message: String,
164}
165
166/// Tower layer that converts error responses into a production error envelope.
167///
168/// Non-error responses pass through unchanged. `4xx` and `5xx` responses are
169/// converted to:
170///
171/// ```json
172/// {
173///   "error": {
174///     "statusCode": 400,
175///     "code": "bad_request",
176///     "message": "invalid input",
177///     "details": null,
178///     "timestamp": "2026-01-01T00:00:00Z",
179///     "path": "/users",
180///     "requestId": "..."
181///   }
182/// }
183/// ```
184///
185/// Legacy/simple Nidus bodies shaped like
186/// `{ "error": { "code": "...", "message": "...", ... } }` are parsed and
187/// wrapped. Extra fields under `error` are preserved as `details` for non-5xx
188/// responses. For all 5xx responses, the client-facing message is masked to
189/// `"internal server error"` and `details` is set to `null`.
190/// Error bodies larger than 64 KiB are not parsed as legacy JSON; oversized
191/// bodies are replaced with the status-derived envelope to avoid unbounded
192/// buffering.
193///
194/// When [`crate::context::RequestContext`] is present, its request ID is copied
195/// into `error.requestId`; otherwise that field is an empty string. Install the
196/// validated request ID and request context layers, or use
197/// [`crate::middleware::ApiDefaults::production`], when clients need stable
198/// request IDs in error responses.
199#[derive(Clone, Copy, Debug, Default)]
200pub struct ErrorEnvelopeLayer;
201
202impl ErrorEnvelopeLayer {
203    /// Creates an error envelope layer.
204    pub fn new() -> Self {
205        Self
206    }
207}
208
209impl<S> Layer<S> for ErrorEnvelopeLayer {
210    type Service = ErrorEnvelopeService<S>;
211
212    fn layer(&self, inner: S) -> Self::Service {
213        ErrorEnvelopeService { inner }
214    }
215}
216
217/// Service produced by [`ErrorEnvelopeLayer`].
218#[derive(Clone, Debug)]
219pub struct ErrorEnvelopeService<S> {
220    inner: S,
221}
222
223impl<S> Service<Request> for ErrorEnvelopeService<S>
224where
225    S: Service<Request, Response = axum::response::Response> + Send + 'static,
226    S::Future: Send + 'static,
227    S::Error: Send + 'static,
228{
229    type Response = axum::response::Response;
230    type Error = S::Error;
231    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
232
233    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
234        self.inner.poll_ready(cx)
235    }
236
237    fn call(&mut self, request: Request) -> Self::Future {
238        // URI and RequestContext clones retain shared backing storage. Defer
239        // allocating owned strings until the response actually needs an error
240        // envelope; successful responses are the common path.
241        let uri = request.uri().clone();
242        let request_context = request.extensions().get::<RequestContext>().cloned();
243        let future = self.inner.call(request);
244
245        Box::pin(async move {
246            let response = future.await?;
247            if !response.status().is_client_error() && !response.status().is_server_error() {
248                return Ok(response);
249            }
250            let request_id = request_context
251                .as_ref()
252                .map(|context| context.request_id().to_owned());
253            let path = uri.path().to_owned();
254            Ok(envelope_response(response, request_id, path).await)
255        })
256    }
257}
258
259async fn envelope_response(
260    response: axum::response::Response,
261    request_id: Option<String>,
262    path: String,
263) -> axum::response::Response {
264    let (mut parts, body) = response.into_parts();
265    let status = parts.status;
266    let extracted = read_legacy_error_body(body).await;
267    let mut code = extracted
268        .as_ref()
269        .map(|body| body.error.code.clone())
270        .unwrap_or_else(|| default_code(status).to_owned());
271    let mut message = extracted
272        .as_ref()
273        .map(|body| body.error.message.clone())
274        .unwrap_or_else(|| status.canonical_reason().unwrap_or("error").to_owned());
275    let mut details = extracted
276        .map(|body| {
277            if body.error.details.is_empty() {
278                serde_json::Value::Null
279            } else {
280                serde_json::Value::Object(body.error.details)
281            }
282        })
283        .unwrap_or(serde_json::Value::Null);
284    if status.is_server_error() {
285        tracing::error!(
286            http.status = status.as_u16(),
287            error.code = %code,
288            request.id = request_id.as_deref().unwrap_or(""),
289            http.path = %path,
290            "http error envelope"
291        );
292        // ERR-1: do not leak internal error taxonomy to clients on a 5xx. The
293        // original code is retained in the structured log above for debugging.
294        message = "internal server error".to_owned();
295        details = serde_json::Value::Null;
296        code = default_code(status).to_owned();
297    }
298
299    let envelope = ProductionErrorBody {
300        error: ProductionErrorDetails {
301            status_code: status.as_u16(),
302            code,
303            message,
304            details,
305            timestamp: timestamp_now(),
306            path,
307            request_id: request_id.unwrap_or_default(),
308        },
309    };
310    let body = serde_json::to_vec(&envelope).expect("error envelope should serialize");
311    // The inner representation has been replaced. Preserve unrelated response
312    // metadata (for example, cookies and rate-limit headers), but never forward
313    // representation headers that describe bytes which are no longer present.
314    parts.headers.remove(http::header::CONTENT_LENGTH);
315    parts.headers.remove(http::header::CONTENT_ENCODING);
316    parts.headers.remove(http::header::CONTENT_RANGE);
317    parts.headers.insert(
318        http::header::CONTENT_TYPE,
319        http::HeaderValue::from_static("application/json"),
320    );
321    axum::response::Response::from_parts(parts, Body::from(body))
322}
323
324const MAX_ERROR_ENVELOPE_BODY_BYTES: usize = 64 * 1024;
325
326async fn read_legacy_error_body(body: Body) -> Option<LegacyErrorBody> {
327    let bytes = to_bytes(body, MAX_ERROR_ENVELOPE_BODY_BYTES).await.ok()?;
328    serde_json::from_slice::<LegacyErrorBody>(&bytes).ok()
329}
330
331/// Returns the current UTC timestamp formatted as RFC3339.
332pub(crate) fn timestamp_now() -> String {
333    time::OffsetDateTime::now_utc()
334        .format(&time::format_description::well_known::Rfc3339)
335        .expect("UTC timestamp should format as RFC3339")
336}
337
338fn default_code(status: StatusCode) -> &'static str {
339    match status {
340        StatusCode::BAD_REQUEST => "bad_request",
341        StatusCode::UNAUTHORIZED => "unauthorized",
342        StatusCode::FORBIDDEN => "forbidden",
343        StatusCode::NOT_FOUND => "not_found",
344        StatusCode::CONFLICT => "conflict",
345        StatusCode::UNPROCESSABLE_ENTITY => "unprocessable_entity",
346        StatusCode::TOO_MANY_REQUESTS => "too_many_requests",
347        status if status.is_server_error() => "internal_server_error",
348        _ => "http_error",
349    }
350}
351
352#[derive(Debug, Deserialize)]
353struct LegacyErrorBody {
354    error: LegacyErrorDetails,
355}
356
357#[derive(Debug, Deserialize)]
358struct LegacyErrorDetails {
359    code: String,
360    message: String,
361    #[serde(flatten)]
362    details: serde_json::Map<String, serde_json::Value>,
363}
364
365#[derive(Debug, Serialize)]
366struct ProductionErrorBody {
367    error: ProductionErrorDetails,
368}
369
370#[derive(Debug, Serialize)]
371#[serde(rename_all = "camelCase")]
372struct ProductionErrorDetails {
373    status_code: u16,
374    code: String,
375    message: String,
376    details: serde_json::Value,
377    timestamp: String,
378    path: String,
379    request_id: String,
380}
381
382/// Invalid route path declared through the manual HTTP routing API.
383#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
384#[error("route path `{path}` contains a parameter segment without a name after ':'")]
385pub struct RoutePathError {
386    path: String,
387}
388
389impl RoutePathError {
390    /// Creates an error for a route path parameter segment without a name.
391    pub fn empty_parameter(path: impl Into<String>) -> Self {
392        Self { path: path.into() }
393    }
394
395    /// Returns the invalid route path.
396    pub fn path(&self) -> &str {
397        &self.path
398    }
399}