Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions src/app_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<Cow<'static, str>>
pub message: Option<Cow<'static, str>>,
/// Optional retry advice rendered as `Retry-After`.
pub retry: Option<RetryAdvice>,
/// Optional authentication challenge for `WWW-Authenticate`.
pub www_authenticate: Option<String>
}

/// Conventional result alias for application code.
Expand Down Expand Up @@ -105,7 +109,9 @@ impl AppError {
pub fn with(kind: AppErrorKind, msg: impl Into<Cow<'static, str>>) -> Self {
Self {
kind,
message: Some(msg.into())
message: Some(msg.into()),
retry: None,
www_authenticate: None
}
}

Expand All @@ -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<String>) -> 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`.
Expand Down Expand Up @@ -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<impl Into<Cow<'static, str>>>) -> 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.
Expand Down
47 changes: 43 additions & 4 deletions src/convert/actix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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");
}
}
28 changes: 14 additions & 14 deletions src/convert/axum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down Expand Up @@ -79,21 +78,21 @@ 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)]
(status, ()).into_response()
}
}


#[cfg(test)]
mod tests {
use super::*;
use crate::{AppErrorKind, AppCode};
use axum::http::StatusCode;

use super::*;
use crate::{AppCode, AppErrorKind};

// --- http_status mapping -------------------------------------------------

#[test]
Expand All @@ -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");
Expand All @@ -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");
}
}

9 changes: 6 additions & 3 deletions src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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()));
}
Expand Down
Loading