use std::borrow::Cow;
use axum::{
http::{
header::{ALLOW, CONTENT_TYPE},
HeaderValue, StatusCode,
},
response::{IntoResponse, Response},
};
use docspec_json::{JsonEmitter, StrusonBackend};
#[derive(Debug)]
pub struct ProblemJson {
pub detail: Cow<'static, str>,
pub status: u16,
pub title: &'static str,
pub type_uri: &'static str,
}
const SERIALIZATION_FALLBACK_JSON: &[u8] = br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Error response serialization failed"}"#;
impl ProblemJson {
fn try_to_json_bytes(&self) -> docspec_core::Result<Vec<u8>> {
let mut emitter = JsonEmitter::new(StrusonBackend::new(Vec::new()));
emitter.object(|builder| {
builder.key("type").value(self.type_uri)?;
builder.key("title").value(self.title)?;
builder.key("status").value(u32::from(self.status))?;
builder.key("detail").value(self.detail.as_ref())?;
Ok(())
})?;
emitter.finish()
}
#[inline]
#[must_use]
pub fn to_json_bytes(&self) -> Vec<u8> {
Self::bytes_or_fallback(self.try_to_json_bytes())
}
fn bytes_or_fallback(serialized: docspec_core::Result<Vec<u8>>) -> Vec<u8> {
serialized.unwrap_or_else(|_| SERIALIZATION_FALLBACK_JSON.to_vec())
}
}
fn problem_response(
problem: &ProblemJson,
status: StatusCode,
allow: Option<&'static str>,
) -> Response {
problem_response_from(problem.try_to_json_bytes(), status, allow)
}
fn problem_response_from(
serialized: docspec_core::Result<Vec<u8>>,
status: StatusCode,
allow: Option<&'static str>,
) -> Response {
let mut response = match serialized {
Ok(body) => {
let mut ok_response = (status, body).into_response();
if let Some(allowed) = allow {
ok_response
.headers_mut()
.insert(ALLOW, HeaderValue::from_static(allowed));
}
ok_response
}
Err(error) => {
tracing::error!(%error, "problem JSON serialization failed; sending static fallback");
(
StatusCode::INTERNAL_SERVER_ERROR,
SERIALIZATION_FALLBACK_JSON,
)
.into_response()
}
};
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/problem+json; charset=utf-8"),
);
response
}
#[derive(Debug)]
#[non_exhaustive]
pub enum HttpError {
BodyNotUtf8,
EmptyBody,
Internal {
source: Box<dyn core::error::Error + Send + Sync + 'static>,
},
MethodNotAllowed {
allowed: &'static str,
},
NotAcceptable,
NotFound {
method: String,
path: String,
},
Unprocessable {
detail: String,
},
UnsupportedMediaType {
received: Option<String>,
},
}
#[cfg(feature = "posthog")]
fn build_posthog_error_event(detail: &str, sentry_event_id: Option<String>) -> posthog_rs::Event {
let distinct_id = uuid::Uuid::new_v4().to_string();
let mut event = posthog_rs::Event::new("$exception", &distinct_id);
drop(event.insert_prop("$exception_message", detail));
drop(event.insert_prop("$is_anonymous", true));
if let Some(sentry_id) = sentry_event_id {
drop(event.insert_prop("sentry_event_id", sentry_id));
}
event
}
#[cfg(feature = "sentry")]
#[inline]
fn capture_sentry(
detail: &str,
source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
) -> sentry::types::Uuid {
source.map_or_else(
|| sentry::capture_message(detail, sentry::Level::Error),
sentry::capture_error,
)
}
#[cfg(any(feature = "sentry", feature = "posthog"))]
fn capture_error_telemetry(
status: StatusCode,
detail: &str,
source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
) {
if status != StatusCode::INTERNAL_SERVER_ERROR && status != StatusCode::UNPROCESSABLE_ENTITY {
return;
}
#[cfg(not(feature = "sentry"))]
let _ = source;
#[cfg(all(feature = "sentry", not(feature = "posthog")))]
{
let _ = capture_sentry(detail, source);
}
#[cfg(all(feature = "sentry", feature = "posthog"))]
let sentry_id_string: Option<String> = Some(capture_sentry(detail, source).to_string());
#[cfg(all(not(feature = "sentry"), feature = "posthog"))]
let sentry_id_string: Option<String> = None;
#[cfg(feature = "posthog")]
{
let client_arc: Option<std::sync::Arc<posthog_rs::Client>> =
match crate::telemetry::posthog_client_slot().read() {
Ok(guard) => guard.as_ref().map(std::sync::Arc::clone),
Err(_poisoned) => {
tracing::warn!("posthog slot poisoned; skipping error capture");
None
}
};
if let Some(client) = client_arc {
let event = build_posthog_error_event(detail, sentry_id_string);
client.capture(event);
}
}
}
impl IntoResponse for HttpError {
#[inline]
fn into_response(self) -> Response {
#[cfg(any(feature = "sentry", feature = "posthog"))]
self.capture_to_telemetry();
let (status, title, detail, allow): (
StatusCode,
&'static str,
Cow<'static, str>,
Option<&'static str>,
) = match self {
Self::EmptyBody => (
StatusCode::BAD_REQUEST,
"Bad Request",
Cow::Borrowed("Request body is empty"),
None,
),
Self::BodyNotUtf8 => (
StatusCode::BAD_REQUEST,
"Bad Request",
Cow::Borrowed("Request body is not valid UTF-8"),
None,
),
Self::NotFound { method, path } => (
StatusCode::NOT_FOUND,
"Not Found",
Cow::Owned(format!("No route matches {method} {path}")),
None,
),
Self::MethodNotAllowed { allowed } => (
StatusCode::METHOD_NOT_ALLOWED,
"Method Not Allowed",
Cow::Owned(format!("Method not allowed. Allowed methods: {allowed}.")),
Some(allowed),
),
Self::NotAcceptable => (
StatusCode::NOT_ACCEPTABLE,
"Not Acceptable",
Cow::Borrowed(
"Accept header must include application/vnd.docspec.blocknote+json, \
application/vnd.blocknote+json, application/vnd.oxa+json, \
application/vnd.pandoc.native, text/html, application/*, or */*",
),
None,
),
Self::UnsupportedMediaType { received: None } => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Unsupported Media Type",
Cow::Borrowed("Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
None,
),
Self::UnsupportedMediaType {
received: Some(content_type),
} => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Unsupported Media Type",
Cow::Owned(format!(
"Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got {content_type}"
)),
None,
),
Self::Unprocessable { detail } => (
StatusCode::UNPROCESSABLE_ENTITY,
"Unprocessable Entity",
Cow::Owned(detail),
None,
),
Self::Internal { .. } => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
Cow::Borrowed("An unexpected error occurred during conversion"),
None,
),
};
let problem = ProblemJson {
detail,
status: status.as_u16(),
title,
type_uri: "about:blank",
};
problem_response(&problem, status, allow)
}
}
impl HttpError {
#[inline]
#[must_use]
pub fn error_class(&self) -> &'static str {
match self {
Self::BodyNotUtf8 => "body_not_utf8",
Self::EmptyBody => "empty_body",
Self::Internal { .. } => "internal",
Self::MethodNotAllowed { .. } => "method_not_allowed",
Self::NotAcceptable => "not_acceptable",
Self::NotFound { .. } => "not_found",
Self::Unprocessable { .. } => "unprocessable",
Self::UnsupportedMediaType { .. } => "unsupported_media_type",
}
}
#[inline]
#[must_use]
pub fn result_class(&self) -> &'static str {
use crate::metrics::{RESULT_CLIENT_ERROR, RESULT_SERVER_ERROR};
match self {
Self::BodyNotUtf8
| Self::EmptyBody
| Self::MethodNotAllowed { .. }
| Self::NotAcceptable
| Self::NotFound { .. }
| Self::Unprocessable { .. }
| Self::UnsupportedMediaType { .. } => RESULT_CLIENT_ERROR,
Self::Internal { .. } => RESULT_SERVER_ERROR,
}
}
#[inline]
#[must_use]
pub fn internal<E>(source: E) -> Self
where
E: core::error::Error + Send + Sync + 'static,
{
Self::Internal {
source: Box::new(source),
}
}
#[cfg(any(feature = "sentry", feature = "posthog"))]
pub(crate) fn capture_to_telemetry(&self) {
match self {
Self::Internal { source } => {
capture_error_telemetry(
StatusCode::INTERNAL_SERVER_ERROR,
"An unexpected error occurred during conversion",
Some(source.as_ref()),
);
}
Self::Unprocessable { detail } => {
capture_error_telemetry(StatusCode::UNPROCESSABLE_ENTITY, detail.as_str(), None);
}
Self::BodyNotUtf8
| Self::EmptyBody
| Self::MethodNotAllowed { .. }
| Self::NotAcceptable
| Self::NotFound { .. }
| Self::UnsupportedMediaType { .. } => {}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
use axum::{
http::{
header::{ALLOW, CONTENT_TYPE},
StatusCode,
},
response::IntoResponse as _,
};
use super::*;
async fn body_bytes(error: HttpError) -> Vec<u8> {
axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
fn dummy_error() -> std::io::Error {
std::io::Error::other("dummy internal error")
}
fn serialization_error() -> docspec_core::Error {
docspec_core::Error::Other {
message: "forced serialization failure".to_string(),
}
}
#[test]
fn bytes_or_fallback_returns_static_body_when_serialization_fails() {
assert_eq!(
ProblemJson::bytes_or_fallback(Err(serialization_error())),
br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Error response serialization failed"}"#
.to_vec()
);
}
#[test]
fn bytes_or_fallback_passes_through_successful_serialization() {
assert_eq!(
ProblemJson::bytes_or_fallback(Ok(b"{\"ok\":true}".to_vec())),
b"{\"ok\":true}".to_vec()
);
}
#[tokio::test]
async fn problem_response_degrades_to_500_fallback_when_serialization_fails() {
let response =
problem_response_from(Err(serialization_error()), StatusCode::BAD_REQUEST, None);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
response.headers().get(CONTENT_TYPE).unwrap(),
"application/problem+json; charset=utf-8"
);
assert_eq!(
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Error response serialization failed"}"#
.to_vec()
);
}
#[tokio::test]
async fn problem_response_drops_allow_header_on_serialization_failure() {
let response = problem_response_from(
Err(serialization_error()),
StatusCode::METHOD_NOT_ALLOWED,
Some("POST, OPTIONS"),
);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(response.headers().get(ALLOW), None);
}
#[test]
fn all_variants_have_correct_status_codes() {
assert_eq!(
HttpError::EmptyBody.into_response().status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
HttpError::BodyNotUtf8.into_response().status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned()
}
.into_response()
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
HttpError::MethodNotAllowed { allowed: "GET" }
.into_response()
.status(),
StatusCode::METHOD_NOT_ALLOWED
);
assert_eq!(
HttpError::NotAcceptable.into_response().status(),
StatusCode::NOT_ACCEPTABLE
);
assert_eq!(
HttpError::UnsupportedMediaType { received: None }
.into_response()
.status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert_eq!(
HttpError::Unprocessable {
detail: "bad".to_owned()
}
.into_response()
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
HttpError::internal(dummy_error()).into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn method_not_allowed_has_allow_header() {
let response = HttpError::MethodNotAllowed { allowed: "GET" }.into_response();
let allow_val = response.headers().get(ALLOW).unwrap();
assert_eq!(allow_val, "GET");
}
#[test]
fn content_type_is_problem_json() {
let response = HttpError::internal(dummy_error()).into_response();
let content_type = response.headers().get(CONTENT_TYPE).unwrap();
assert_eq!(content_type, "application/problem+json; charset=utf-8");
}
#[test]
fn no_allow_header_on_non_405_variants() {
let response = HttpError::internal(dummy_error()).into_response();
assert!(response.headers().get(ALLOW).is_none());
}
#[cfg(feature = "sentry")]
#[test]
fn internal_error_is_captured_by_sentry_with_source_chain() {
let events = sentry::test::with_captured_events(|| {
let _response = HttpError::internal(dummy_error()).into_response();
});
assert_eq!(events.len(), 1);
assert_eq!(events[0].level, sentry::Level::Error);
assert!(
events[0].message.is_none(),
"capture_error must not populate top-level message"
);
let first_exception = events[0]
.exception
.values
.first()
.expect("exception chain must be populated by capture_error");
assert_eq!(
first_exception.value.as_deref(),
Some("dummy internal error"),
"exception value must equal the Display of the source error"
);
}
#[cfg(feature = "sentry")]
#[test]
fn unprocessable_error_is_captured_by_sentry() {
let events = sentry::test::with_captured_events(|| {
let _response = HttpError::Unprocessable {
detail: "bad input".to_owned(),
}
.into_response();
});
assert_eq!(events.len(), 1);
assert_eq!(events[0].level, sentry::Level::Error);
assert_eq!(events[0].message.as_deref(), Some("bad input"));
}
#[cfg(feature = "sentry")]
#[test]
fn client_errors_are_not_captured_by_sentry() {
let events = sentry::test::with_captured_events(|| {
drop(HttpError::EmptyBody.into_response());
drop(HttpError::BodyNotUtf8.into_response());
drop(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/x".to_owned(),
}
.into_response(),
);
drop(HttpError::MethodNotAllowed { allowed: "GET" }.into_response());
drop(HttpError::NotAcceptable.into_response());
drop(HttpError::UnsupportedMediaType { received: None }.into_response());
});
assert_eq!(events.len(), 0, "4xx errors must not be captured");
}
#[tokio::test]
async fn serializes_with_four_fields() {
let bytes = body_bytes(HttpError::internal(dummy_error())).await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred during conversion",
})
);
}
#[tokio::test]
async fn no_instance_key_in_output() {
let bytes = body_bytes(HttpError::EmptyBody).await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
json.get("instance").is_none(),
"unexpected 'instance' key in output"
);
}
#[tokio::test]
async fn not_found_problem_body_is_exact() {
let bytes = body_bytes(HttpError::NotFound {
method: "GET".to_owned(),
path: "/api/v99".to_owned(),
})
.await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No route matches GET /api/v99",
})
);
}
#[tokio::test]
async fn internal_detail_is_fixed() {
let bytes = body_bytes(HttpError::internal(dummy_error())).await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json["detail"].as_str().unwrap(),
"An unexpected error occurred during conversion"
);
}
#[tokio::test]
async fn unsupported_media_type_with_received_problem_body_is_exact() {
let bytes = body_bytes(HttpError::UnsupportedMediaType {
received: Some("application/json".to_owned()),
})
.await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Unsupported Media Type",
"status": 415,
"detail": "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got application/json",
})
);
}
#[tokio::test]
async fn unprocessable_problem_body_is_exact() {
let message = "heading level jumped from 1 to 3".to_owned();
let bytes = body_bytes(HttpError::Unprocessable {
detail: message.clone(),
})
.await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": message,
})
);
}
#[tokio::test]
async fn control_char_in_detail_is_escaped() {
let bytes = body_bytes(HttpError::Unprocessable {
detail: "bad\x01input".to_owned(),
})
.await;
assert_eq!(
bytes.as_slice(),
br#"{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"bad\u0001input"}"#
);
}
#[test]
fn body_not_utf8_error_class_returns_body_not_utf8() {
assert_eq!(HttpError::BodyNotUtf8.error_class(), "body_not_utf8");
}
#[test]
fn empty_body_error_class_returns_empty_body() {
assert_eq!(HttpError::EmptyBody.error_class(), "empty_body");
}
#[test]
fn internal_error_class_returns_internal() {
assert_eq!(HttpError::internal(dummy_error()).error_class(), "internal");
}
#[test]
fn method_not_allowed_error_class_returns_method_not_allowed() {
assert_eq!(
HttpError::MethodNotAllowed { allowed: "GET" }.error_class(),
"method_not_allowed"
);
}
#[test]
fn not_acceptable_error_class_returns_not_acceptable() {
assert_eq!(HttpError::NotAcceptable.error_class(), "not_acceptable");
}
#[test]
fn not_found_error_class_returns_not_found() {
assert_eq!(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned()
}
.error_class(),
"not_found"
);
}
#[test]
fn unprocessable_error_class_returns_unprocessable() {
assert_eq!(
HttpError::Unprocessable {
detail: "bad".to_owned()
}
.error_class(),
"unprocessable"
);
}
#[test]
fn unsupported_media_type_error_class_returns_unsupported_media_type() {
assert_eq!(
HttpError::UnsupportedMediaType { received: None }.error_class(),
"unsupported_media_type"
);
}
#[test]
fn body_not_utf8_result_class_returns_client_error() {
assert_eq!(HttpError::BodyNotUtf8.result_class(), "client_error");
}
#[test]
fn empty_body_result_class_returns_client_error() {
assert_eq!(HttpError::EmptyBody.result_class(), "client_error");
}
#[test]
fn internal_result_class_returns_server_error() {
assert_eq!(
HttpError::internal(dummy_error()).result_class(),
"server_error"
);
}
#[test]
fn method_not_allowed_result_class_returns_client_error() {
assert_eq!(
HttpError::MethodNotAllowed { allowed: "GET" }.result_class(),
"client_error"
);
}
#[test]
fn not_acceptable_result_class_returns_client_error() {
assert_eq!(HttpError::NotAcceptable.result_class(), "client_error");
}
#[test]
fn not_found_result_class_returns_client_error() {
assert_eq!(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned()
}
.result_class(),
"client_error"
);
}
#[test]
fn unprocessable_result_class_returns_client_error() {
assert_eq!(
HttpError::Unprocessable {
detail: "bad".to_owned()
}
.result_class(),
"client_error"
);
}
#[test]
fn unsupported_media_type_result_class_returns_client_error() {
assert_eq!(
HttpError::UnsupportedMediaType { received: None }.result_class(),
"client_error"
);
}
#[cfg(feature = "posthog")]
mod posthog_tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::super::HttpError;
use super::dummy_error;
use axum::response::IntoResponse as _;
#[cfg(all(feature = "sentry", feature = "posthog"))]
#[test]
fn internal_error_into_response_does_not_panic_with_both_features() {
let response = HttpError::internal(dummy_error()).into_response();
assert_eq!(
response.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
#[cfg(all(feature = "posthog", not(feature = "sentry")))]
#[test]
fn internal_error_into_response_does_not_panic_with_posthog_only() {
let response = HttpError::internal(dummy_error()).into_response();
assert_eq!(
response.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn client_error_returns_correct_status_and_posthog_block_is_bypassed() {
let response = HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned(),
}
.into_response();
assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
}
#[test]
fn into_response_outside_tokio_runtime_does_not_panic() {
let response = HttpError::internal(dummy_error()).into_response();
assert_eq!(
response.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
}
}