diff --git a/src/app_error.rs b/src/app_error.rs index 1cbb79d..8531673 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -62,7 +62,7 @@ use std::borrow::Cow; use thiserror::Error; use tracing::error; -use crate::{code::AppCode, kind::AppErrorKind}; +use crate::{RetryAdvice, code::AppCode, kind::AppErrorKind}; /// Thin error wrapper: kind + optional message. /// @@ -73,9 +73,13 @@ use crate::{code::AppCode, kind::AppErrorKind}; #[error("{kind}")] pub struct AppError { /// Semantic category of the error. - pub kind: AppErrorKind, + pub kind: AppErrorKind, /// Optional, public-friendly message. - pub message: Option> + pub message: Option>, + /// Optional retry advice rendered as `Retry-After`. + pub retry: Option, + /// Optional authentication challenge for `WWW-Authenticate`. + pub www_authenticate: Option } /// Conventional result alias for application code. @@ -105,7 +109,9 @@ impl AppError { pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { Self { kind, - message: Some(msg.into()) + message: Some(msg.into()), + retry: None, + www_authenticate: None } } @@ -115,10 +121,30 @@ impl AppError { pub fn bare(kind: AppErrorKind) -> Self { Self { kind, - message: None + message: None, + retry: None, + www_authenticate: None } } + /// Attach retry advice to the error. + /// + /// When mapped to HTTP, this becomes the `Retry-After` header. + #[must_use] + pub fn with_retry_after_secs(mut self, secs: u64) -> Self { + self.retry = Some(RetryAdvice { + after_seconds: secs + }); + self + } + + /// Attach a `WWW-Authenticate` challenge string. + #[must_use] + pub fn with_www_authenticate(mut self, value: impl Into) -> Self { + self.www_authenticate = Some(value.into()); + self + } + /// Log the error once at the boundary with stable fields. /// /// Emits a `tracing::error!` with `kind`, `code` and optional `message`. @@ -191,8 +217,10 @@ impl AppError { /// when you may or may not have a safe-to-print string at hand. pub fn database(msg: Option>>) -> Self { Self { - kind: AppErrorKind::Database, - message: msg.map(Into::into) + kind: AppErrorKind::Database, + message: msg.map(Into::into), + retry: None, + www_authenticate: None } } /// Build a `Config` error. diff --git a/src/convert/actix.rs b/src/convert/actix.rs index d49ce02..2871dae 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -61,7 +61,13 @@ //! See also: Axum integration in [`convert::axum`]. #[cfg(feature = "actix")] -use actix_web::{HttpResponse, ResponseError, http::StatusCode as ActixStatus}; +use actix_web::{ + HttpResponse, ResponseError, + http::{ + StatusCode as ActixStatus, + header::{RETRY_AFTER, WWW_AUTHENTICATE} + } +}; #[cfg(feature = "actix")] use crate::{AppError, ErrorResponse}; @@ -78,19 +84,52 @@ impl ResponseError for AppError { /// Produce JSON body with `ErrorResponse`. Does not leak sources. fn error_response(&self) -> HttpResponse { let body = ErrorResponse::from(self); - HttpResponse::build(self.status_code()).json(body) + let mut builder = HttpResponse::build(self.status_code()); + if let Some(retry) = body.retry { + builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); + } + if let Some(ref ch) = body.www_authenticate { + builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); + } + builder.json(body) } } #[cfg(all(test, feature = "actix"))] mod actix_tests { - use actix_web::ResponseError; + use actix_web::{ + ResponseError, + body::to_bytes, + http::header::{RETRY_AFTER, WWW_AUTHENTICATE}, + rt::System + }; - use crate::{AppError, AppErrorKind}; + use crate::{AppCode, AppError, AppErrorKind, ErrorResponse}; #[test] fn maps_status_consistently() { let e = AppError::new(AppErrorKind::Validation, "bad"); assert_eq!(e.status_code().as_u16(), 422); } + + #[test] + fn error_response_sets_body_and_headers() { + let err = AppError::unauthorized("no token") + .with_retry_after_secs(7) + .with_www_authenticate("Bearer"); + + let resp = err.error_response(); + assert_eq!(resp.status().as_u16(), 401); + + let headers = resp.headers().clone(); + assert_eq!(headers.get(RETRY_AFTER).unwrap(), "7"); + assert_eq!(headers.get(WWW_AUTHENTICATE).unwrap(), "Bearer"); + + let bytes = System::new() + .block_on(async { to_bytes(resp.into_body(), usize::MAX).await.expect("body") }); + let body: ErrorResponse = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!(body.status, 401); + assert!(matches!(body.code, AppCode::Unauthorized)); + assert_eq!(body.message, "no token"); + } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 6cd384b..e80815e 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -42,14 +42,13 @@ use axum::{ http::StatusCode, - response::{IntoResponse, Response}, - Json + response::{IntoResponse, Response} }; use tracing::error; +use crate::AppError; #[cfg(feature = "serde_json")] use crate::response::ErrorResponse; -use crate::AppError; impl AppError { /// Map this error to an HTTP status derived from its [`AppErrorKind`]. @@ -79,7 +78,7 @@ impl IntoResponse for AppError { { // Build the stable wire contract (includes `code`). let body: ErrorResponse = (&self).into(); - return (status, Json(body)).into_response(); + return body.into_response(); } #[allow(unreachable_code)] @@ -87,13 +86,13 @@ impl IntoResponse for AppError { } } - #[cfg(test)] mod tests { - use super::*; - use crate::{AppErrorKind, AppCode}; use axum::http::StatusCode; + use super::*; + use crate::{AppCode, AppErrorKind}; + // --- http_status mapping ------------------------------------------------- #[test] @@ -111,15 +110,16 @@ mod tests { #[cfg(feature = "serde_json")] #[tokio::test] async fn into_response_builds_json_error_with_code_and_message() { - use axum::response::IntoResponse; - use axum::body::to_bytes; + use axum::{body::to_bytes, response::IntoResponse}; let app_err = AppError::unauthorized("missing token"); let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("read body"); + let bytes = to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); // Deserialize via our own type to ensure wire contract matches let body: crate::response::ErrorResponse = serde_json::from_slice(&bytes).expect("json body"); @@ -142,16 +142,16 @@ mod tests { #[cfg(not(feature = "serde_json"))] #[tokio::test] async fn into_response_without_json_has_empty_body() { - use axum::response::IntoResponse; - use axum::body::to_bytes; + use axum::{body::to_bytes, response::IntoResponse}; let app_err = AppError::not_found("nope"); let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); - let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("read body"); + let bytes = to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); assert_eq!(bytes.len(), 0, "body should be empty without serde_json"); } } - diff --git a/src/response.rs b/src/response.rs index f4acb46..7763f39 100644 --- a/src/response.rs +++ b/src/response.rs @@ -307,8 +307,8 @@ impl From<&AppError> for ErrorResponse { code, message, details: None, - retry: None, - www_authenticate: None + retry: err.retry, + www_authenticate: err.www_authenticate.clone() } } } @@ -390,7 +390,10 @@ mod actix_impl { type Body = BoxBody; fn respond_to(self, _req: &HttpRequest) -> HttpResponse { - let mut builder = HttpResponse::build(self.status_code()); + let mut builder = HttpResponse::build( + actix_web::http::StatusCode::from_u16(self.status) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) + ); if let Some(retry) = self.retry { builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); }