1#![allow(clippy::std_instead_of_alloc)]
6
7use std::borrow::Cow;
8
9use axum::{
10 http::{
11 header::{ALLOW, CONTENT_TYPE},
12 HeaderValue, StatusCode,
13 },
14 response::{IntoResponse, Response},
15};
16use docspec_json::{JsonEmitter, StrusonBackend};
17
18#[derive(Debug)]
24pub struct ProblemJson {
25 pub detail: Cow<'static, str>,
30 pub status: u16,
32 pub title: &'static str,
36 pub type_uri: &'static str,
40}
41
42impl ProblemJson {
43 #[inline]
59 #[must_use]
60 pub fn to_json_bytes(&self) -> Vec<u8> {
61 #[allow(clippy::expect_used)]
66 {
67 let mut emitter = JsonEmitter::new(StrusonBackend::new(Vec::new()));
68 emitter
69 .object(|builder| {
70 builder.key("type").value(self.type_uri)?;
71 builder.key("title").value(self.title)?;
72 builder.key("status").value(u32::from(self.status))?;
73 builder.key("detail").value(self.detail.as_ref())?;
74 Ok(())
75 })
76 .expect("ProblemJson object emission is infallible");
77 emitter.finish().expect("ProblemJson finish is infallible")
78 }
79 }
80}
81
82#[derive(Debug)]
87#[non_exhaustive]
88pub enum HttpError {
89 BodyNotUtf8,
93 EmptyBody,
97 Internal {
107 source: Box<dyn core::error::Error + Send + Sync + 'static>,
109 },
110 MethodNotAllowed {
114 allowed: &'static str,
116 },
117 NotAcceptable,
121 NotFound {
125 method: String,
127 path: String,
129 },
130 Unprocessable {
134 detail: String,
136 },
137 UnsupportedMediaType {
141 received: Option<String>,
143 },
144}
145
146#[cfg(feature = "posthog")]
153fn build_posthog_error_event(detail: &str, sentry_event_id: Option<String>) -> posthog_rs::Event {
154 let distinct_id = uuid::Uuid::new_v4().to_string();
155 let mut event = posthog_rs::Event::new("$exception", &distinct_id);
156 drop(event.insert_prop("$exception_message", detail));
157 drop(event.insert_prop("$is_anonymous", true));
158 if let Some(sentry_id) = sentry_event_id {
159 drop(event.insert_prop("sentry_event_id", sentry_id));
160 }
161 event
162}
163
164#[cfg(feature = "sentry")]
165#[inline]
166fn capture_sentry(
167 detail: &str,
168 source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
169) -> sentry::types::Uuid {
170 source.map_or_else(
171 || sentry::capture_message(detail, sentry::Level::Error),
172 sentry::capture_error,
173 )
174}
175
176#[cfg(any(feature = "sentry", feature = "posthog"))]
177fn capture_error_telemetry(
178 status: StatusCode,
179 detail: &str,
180 source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
181) {
182 if status != StatusCode::INTERNAL_SERVER_ERROR && status != StatusCode::UNPROCESSABLE_ENTITY {
183 return;
184 }
185
186 #[cfg(not(feature = "sentry"))]
187 let _ = source;
188
189 #[cfg(all(feature = "sentry", not(feature = "posthog")))]
190 {
191 let _ = capture_sentry(detail, source);
192 }
193 #[cfg(all(feature = "sentry", feature = "posthog"))]
194 let sentry_id_string: Option<String> = Some(capture_sentry(detail, source).to_string());
195 #[cfg(all(not(feature = "sentry"), feature = "posthog"))]
196 let sentry_id_string: Option<String> = None;
197 #[cfg(feature = "posthog")]
198 {
199 let client_arc: Option<std::sync::Arc<posthog_rs::Client>> =
200 match crate::telemetry::posthog_client_slot().read() {
201 Ok(guard) => guard.as_ref().map(std::sync::Arc::clone),
202 Err(_poisoned) => {
203 tracing::warn!("posthog slot poisoned; skipping error capture");
204 None
205 }
206 };
207 if let Some(client) = client_arc {
208 let event = build_posthog_error_event(detail, sentry_id_string);
209 client.capture(event);
210 }
211 }
212}
213
214impl IntoResponse for HttpError {
215 #[inline]
221 fn into_response(self) -> Response {
222 #[cfg(any(feature = "sentry", feature = "posthog"))]
223 self.capture_to_telemetry();
224
225 let (status, title, detail, allow): (
226 StatusCode,
227 &'static str,
228 Cow<'static, str>,
229 Option<&'static str>,
230 ) = match self {
231 Self::EmptyBody => (
232 StatusCode::BAD_REQUEST,
233 "Bad Request",
234 Cow::Borrowed("Request body is empty"),
235 None,
236 ),
237 Self::BodyNotUtf8 => (
238 StatusCode::BAD_REQUEST,
239 "Bad Request",
240 Cow::Borrowed("Request body is not valid UTF-8"),
241 None,
242 ),
243 Self::NotFound { method, path } => (
244 StatusCode::NOT_FOUND,
245 "Not Found",
246 Cow::Owned(format!("No route matches {method} {path}")),
247 None,
248 ),
249 Self::MethodNotAllowed { allowed } => (
250 StatusCode::METHOD_NOT_ALLOWED,
251 "Method Not Allowed",
252 Cow::Owned(format!("Method not allowed. Allowed methods: {allowed}.")),
253 Some(allowed),
254 ),
255 Self::NotAcceptable => (
256 StatusCode::NOT_ACCEPTABLE,
257 "Not Acceptable",
258 Cow::Borrowed(
259 "Accept header must include application/vnd.docspec.blocknote+json, \
260 application/vnd.blocknote+json, application/vnd.oxa+json, \
261 application/vnd.pandoc.native, text/html, application/*, or */*",
262 ),
263 None,
264 ),
265 Self::UnsupportedMediaType { received: None } => (
266 StatusCode::UNSUPPORTED_MEDIA_TYPE,
267 "Unsupported Media Type",
268 Cow::Borrowed("Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
269 None,
270 ),
271 Self::UnsupportedMediaType {
272 received: Some(content_type),
273 } => (
274 StatusCode::UNSUPPORTED_MEDIA_TYPE,
275 "Unsupported Media Type",
276 Cow::Owned(format!(
277 "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got {content_type}"
278 )),
279 None,
280 ),
281 Self::Unprocessable { detail } => (
282 StatusCode::UNPROCESSABLE_ENTITY,
283 "Unprocessable Entity",
284 Cow::Owned(detail),
285 None,
286 ),
287 Self::Internal { .. } => (
288 StatusCode::INTERNAL_SERVER_ERROR,
289 "Internal Server Error",
290 Cow::Borrowed("An unexpected error occurred during conversion"),
291 None,
292 ),
293 };
294
295 let body = ProblemJson {
296 detail,
297 status: status.as_u16(),
298 title,
299 type_uri: "about:blank",
300 }
301 .to_json_bytes();
302
303 let mut response = (status, body).into_response();
304 response.headers_mut().insert(
305 CONTENT_TYPE,
306 HeaderValue::from_static("application/problem+json; charset=utf-8"),
307 );
308 if let Some(allowed) = allow {
309 response
310 .headers_mut()
311 .insert(ALLOW, HeaderValue::from_static(allowed));
312 }
313 response
314 }
315}
316
317impl HttpError {
318 #[inline]
321 #[must_use]
322 pub fn error_class(&self) -> &'static str {
323 match self {
324 Self::BodyNotUtf8 => "body_not_utf8",
325 Self::EmptyBody => "empty_body",
326 Self::Internal { .. } => "internal",
327 Self::MethodNotAllowed { .. } => "method_not_allowed",
328 Self::NotAcceptable => "not_acceptable",
329 Self::NotFound { .. } => "not_found",
330 Self::Unprocessable { .. } => "unprocessable",
331 Self::UnsupportedMediaType { .. } => "unsupported_media_type",
332 }
333 }
334
335 #[inline]
337 #[must_use]
338 pub fn result_class(&self) -> &'static str {
339 use crate::metrics::{RESULT_CLIENT_ERROR, RESULT_SERVER_ERROR};
340 match self {
341 Self::BodyNotUtf8
342 | Self::EmptyBody
343 | Self::MethodNotAllowed { .. }
344 | Self::NotAcceptable
345 | Self::NotFound { .. }
346 | Self::Unprocessable { .. }
347 | Self::UnsupportedMediaType { .. } => RESULT_CLIENT_ERROR,
348 Self::Internal { .. } => RESULT_SERVER_ERROR,
349 }
350 }
351
352 #[inline]
358 #[must_use]
359 pub fn internal<E>(source: E) -> Self
360 where
361 E: core::error::Error + Send + Sync + 'static,
362 {
363 Self::Internal {
364 source: Box::new(source),
365 }
366 }
367
368 #[cfg(any(feature = "sentry", feature = "posthog"))]
371 pub(crate) fn capture_to_telemetry(&self) {
372 match self {
373 Self::Internal { source } => {
374 capture_error_telemetry(
375 StatusCode::INTERNAL_SERVER_ERROR,
376 "An unexpected error occurred during conversion",
377 Some(source.as_ref()),
378 );
379 }
380 Self::Unprocessable { detail } => {
381 capture_error_telemetry(StatusCode::UNPROCESSABLE_ENTITY, detail.as_str(), None);
382 }
383 Self::BodyNotUtf8
384 | Self::EmptyBody
385 | Self::MethodNotAllowed { .. }
386 | Self::NotAcceptable
387 | Self::NotFound { .. }
388 | Self::UnsupportedMediaType { .. } => {}
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
398
399 use axum::{
400 http::{
401 header::{ALLOW, CONTENT_TYPE},
402 StatusCode,
403 },
404 response::IntoResponse as _,
405 };
406
407 use super::*;
408
409 async fn body_bytes(error: HttpError) -> Vec<u8> {
410 axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
411 .await
412 .unwrap()
413 .to_vec()
414 }
415
416 fn dummy_error() -> std::io::Error {
417 std::io::Error::other("dummy internal error")
418 }
419
420 #[test]
421 fn all_variants_have_correct_status_codes() {
422 assert_eq!(
423 HttpError::EmptyBody.into_response().status(),
424 StatusCode::BAD_REQUEST
425 );
426 assert_eq!(
427 HttpError::BodyNotUtf8.into_response().status(),
428 StatusCode::BAD_REQUEST
429 );
430 assert_eq!(
431 HttpError::NotFound {
432 method: "GET".to_owned(),
433 path: "/foo".to_owned()
434 }
435 .into_response()
436 .status(),
437 StatusCode::NOT_FOUND
438 );
439 assert_eq!(
440 HttpError::MethodNotAllowed { allowed: "GET" }
441 .into_response()
442 .status(),
443 StatusCode::METHOD_NOT_ALLOWED
444 );
445 assert_eq!(
446 HttpError::NotAcceptable.into_response().status(),
447 StatusCode::NOT_ACCEPTABLE
448 );
449 assert_eq!(
450 HttpError::UnsupportedMediaType { received: None }
451 .into_response()
452 .status(),
453 StatusCode::UNSUPPORTED_MEDIA_TYPE
454 );
455 assert_eq!(
456 HttpError::Unprocessable {
457 detail: "bad".to_owned()
458 }
459 .into_response()
460 .status(),
461 StatusCode::UNPROCESSABLE_ENTITY
462 );
463 assert_eq!(
464 HttpError::internal(dummy_error()).into_response().status(),
465 StatusCode::INTERNAL_SERVER_ERROR
466 );
467 }
468
469 #[test]
470 fn method_not_allowed_has_allow_header() {
471 let response = HttpError::MethodNotAllowed { allowed: "GET" }.into_response();
472 let allow_val = response.headers().get(ALLOW).unwrap();
473 assert_eq!(allow_val, "GET");
474 }
475
476 #[test]
477 fn content_type_is_problem_json() {
478 let response = HttpError::internal(dummy_error()).into_response();
479 let content_type = response.headers().get(CONTENT_TYPE).unwrap();
480 assert_eq!(content_type, "application/problem+json; charset=utf-8");
481 }
482
483 #[test]
484 fn no_allow_header_on_non_405_variants() {
485 let response = HttpError::internal(dummy_error()).into_response();
486 assert!(response.headers().get(ALLOW).is_none());
487 }
488
489 #[cfg(feature = "sentry")]
490 #[test]
491 fn internal_error_is_captured_by_sentry_with_source_chain() {
492 let events = sentry::test::with_captured_events(|| {
493 let _response = HttpError::internal(dummy_error()).into_response();
494 });
495 assert_eq!(events.len(), 1);
496 assert_eq!(events[0].level, sentry::Level::Error);
497 assert!(
498 events[0].message.is_none(),
499 "capture_error must not populate top-level message"
500 );
501 let first_exception = events[0]
502 .exception
503 .values
504 .first()
505 .expect("exception chain must be populated by capture_error");
506 assert_eq!(
507 first_exception.value.as_deref(),
508 Some("dummy internal error"),
509 "exception value must equal the Display of the source error"
510 );
511 }
512
513 #[cfg(feature = "sentry")]
514 #[test]
515 fn unprocessable_error_is_captured_by_sentry() {
516 let events = sentry::test::with_captured_events(|| {
517 let _response = HttpError::Unprocessable {
518 detail: "bad input".to_owned(),
519 }
520 .into_response();
521 });
522 assert_eq!(events.len(), 1);
523 assert_eq!(events[0].level, sentry::Level::Error);
524 assert_eq!(events[0].message.as_deref(), Some("bad input"));
525 }
526
527 #[cfg(feature = "sentry")]
528 #[test]
529 fn client_errors_are_not_captured_by_sentry() {
530 let events = sentry::test::with_captured_events(|| {
531 drop(HttpError::EmptyBody.into_response());
532 drop(HttpError::BodyNotUtf8.into_response());
533 drop(
534 HttpError::NotFound {
535 method: "GET".to_owned(),
536 path: "/x".to_owned(),
537 }
538 .into_response(),
539 );
540 drop(HttpError::MethodNotAllowed { allowed: "GET" }.into_response());
541 drop(HttpError::NotAcceptable.into_response());
542 drop(HttpError::UnsupportedMediaType { received: None }.into_response());
543 });
544 assert_eq!(events.len(), 0, "4xx errors must not be captured");
545 }
546
547 #[tokio::test]
548 async fn serializes_with_four_fields() {
549 let bytes = body_bytes(HttpError::internal(dummy_error())).await;
550 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
551 assert_eq!(
552 json,
553 serde_json::json!({
554 "type": "about:blank",
555 "title": "Internal Server Error",
556 "status": 500,
557 "detail": "An unexpected error occurred during conversion",
558 })
559 );
560 }
561
562 #[tokio::test]
563 async fn no_instance_key_in_output() {
564 let bytes = body_bytes(HttpError::EmptyBody).await;
565 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
566 assert!(
567 json.get("instance").is_none(),
568 "unexpected 'instance' key in output"
569 );
570 }
571
572 #[tokio::test]
573 async fn not_found_problem_body_is_exact() {
574 let bytes = body_bytes(HttpError::NotFound {
575 method: "GET".to_owned(),
576 path: "/api/v99".to_owned(),
577 })
578 .await;
579 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
580 assert_eq!(
581 json,
582 serde_json::json!({
583 "type": "about:blank",
584 "title": "Not Found",
585 "status": 404,
586 "detail": "No route matches GET /api/v99",
587 })
588 );
589 }
590
591 #[tokio::test]
592 async fn internal_detail_is_fixed() {
593 let bytes = body_bytes(HttpError::internal(dummy_error())).await;
594 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
595 assert_eq!(
596 json["detail"].as_str().unwrap(),
597 "An unexpected error occurred during conversion"
598 );
599 }
600
601 #[tokio::test]
602 async fn unsupported_media_type_with_received_problem_body_is_exact() {
603 let bytes = body_bytes(HttpError::UnsupportedMediaType {
604 received: Some("application/json".to_owned()),
605 })
606 .await;
607 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
608 assert_eq!(
609 json,
610 serde_json::json!({
611 "type": "about:blank",
612 "title": "Unsupported Media Type",
613 "status": 415,
614 "detail": "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got application/json",
615 })
616 );
617 }
618
619 #[tokio::test]
620 async fn unprocessable_problem_body_is_exact() {
621 let message = "heading level jumped from 1 to 3".to_owned();
622 let bytes = body_bytes(HttpError::Unprocessable {
623 detail: message.clone(),
624 })
625 .await;
626 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
627 assert_eq!(
628 json,
629 serde_json::json!({
630 "type": "about:blank",
631 "title": "Unprocessable Entity",
632 "status": 422,
633 "detail": message,
634 })
635 );
636 }
637
638 #[tokio::test]
639 async fn control_char_in_detail_is_escaped() {
640 let bytes = body_bytes(HttpError::Unprocessable {
641 detail: "bad\x01input".to_owned(),
642 })
643 .await;
644 assert_eq!(
645 bytes.as_slice(),
646 br#"{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"bad\u0001input"}"#
647 );
648 }
649
650 #[test]
651 fn body_not_utf8_error_class_returns_body_not_utf8() {
652 assert_eq!(HttpError::BodyNotUtf8.error_class(), "body_not_utf8");
653 }
654
655 #[test]
656 fn empty_body_error_class_returns_empty_body() {
657 assert_eq!(HttpError::EmptyBody.error_class(), "empty_body");
658 }
659
660 #[test]
661 fn internal_error_class_returns_internal() {
662 assert_eq!(HttpError::internal(dummy_error()).error_class(), "internal");
663 }
664
665 #[test]
666 fn method_not_allowed_error_class_returns_method_not_allowed() {
667 assert_eq!(
668 HttpError::MethodNotAllowed { allowed: "GET" }.error_class(),
669 "method_not_allowed"
670 );
671 }
672
673 #[test]
674 fn not_acceptable_error_class_returns_not_acceptable() {
675 assert_eq!(HttpError::NotAcceptable.error_class(), "not_acceptable");
676 }
677
678 #[test]
679 fn not_found_error_class_returns_not_found() {
680 assert_eq!(
681 HttpError::NotFound {
682 method: "GET".to_owned(),
683 path: "/foo".to_owned()
684 }
685 .error_class(),
686 "not_found"
687 );
688 }
689
690 #[test]
691 fn unprocessable_error_class_returns_unprocessable() {
692 assert_eq!(
693 HttpError::Unprocessable {
694 detail: "bad".to_owned()
695 }
696 .error_class(),
697 "unprocessable"
698 );
699 }
700
701 #[test]
702 fn unsupported_media_type_error_class_returns_unsupported_media_type() {
703 assert_eq!(
704 HttpError::UnsupportedMediaType { received: None }.error_class(),
705 "unsupported_media_type"
706 );
707 }
708
709 #[test]
710 fn body_not_utf8_result_class_returns_client_error() {
711 assert_eq!(HttpError::BodyNotUtf8.result_class(), "client_error");
712 }
713
714 #[test]
715 fn empty_body_result_class_returns_client_error() {
716 assert_eq!(HttpError::EmptyBody.result_class(), "client_error");
717 }
718
719 #[test]
720 fn internal_result_class_returns_server_error() {
721 assert_eq!(
722 HttpError::internal(dummy_error()).result_class(),
723 "server_error"
724 );
725 }
726
727 #[test]
728 fn method_not_allowed_result_class_returns_client_error() {
729 assert_eq!(
730 HttpError::MethodNotAllowed { allowed: "GET" }.result_class(),
731 "client_error"
732 );
733 }
734
735 #[test]
736 fn not_acceptable_result_class_returns_client_error() {
737 assert_eq!(HttpError::NotAcceptable.result_class(), "client_error");
738 }
739
740 #[test]
741 fn not_found_result_class_returns_client_error() {
742 assert_eq!(
743 HttpError::NotFound {
744 method: "GET".to_owned(),
745 path: "/foo".to_owned()
746 }
747 .result_class(),
748 "client_error"
749 );
750 }
751
752 #[test]
753 fn unprocessable_result_class_returns_client_error() {
754 assert_eq!(
755 HttpError::Unprocessable {
756 detail: "bad".to_owned()
757 }
758 .result_class(),
759 "client_error"
760 );
761 }
762
763 #[test]
764 fn unsupported_media_type_result_class_returns_client_error() {
765 assert_eq!(
766 HttpError::UnsupportedMediaType { received: None }.result_class(),
767 "client_error"
768 );
769 }
770
771 #[cfg(feature = "posthog")]
772 mod posthog_tests {
773 #![allow(clippy::expect_used, clippy::unwrap_used)]
774 use super::super::HttpError;
775 use super::dummy_error;
776 use axum::response::IntoResponse as _;
777
778 #[cfg(all(feature = "sentry", feature = "posthog"))]
779 #[test]
780 fn internal_error_into_response_does_not_panic_with_both_features() {
781 let response = HttpError::internal(dummy_error()).into_response();
782 assert_eq!(
783 response.status(),
784 axum::http::StatusCode::INTERNAL_SERVER_ERROR
785 );
786 }
787
788 #[cfg(all(feature = "posthog", not(feature = "sentry")))]
789 #[test]
790 fn internal_error_into_response_does_not_panic_with_posthog_only() {
791 let response = HttpError::internal(dummy_error()).into_response();
792 assert_eq!(
793 response.status(),
794 axum::http::StatusCode::INTERNAL_SERVER_ERROR
795 );
796 }
797
798 #[test]
799 fn client_error_returns_correct_status_and_posthog_block_is_bypassed() {
800 let response = HttpError::NotFound {
801 method: "GET".to_owned(),
802 path: "/foo".to_owned(),
803 }
804 .into_response();
805 assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
806 }
807
808 #[test]
809 fn into_response_outside_tokio_runtime_does_not_panic() {
810 let response = HttpError::internal(dummy_error()).into_response();
811 assert_eq!(
812 response.status(),
813 axum::http::StatusCode::INTERNAL_SERVER_ERROR
814 );
815 }
816 }
817}