1use std::borrow::Cow;
4
5use axum::{
6 http::{
7 header::{ALLOW, CONTENT_TYPE},
8 HeaderValue, StatusCode,
9 },
10 response::{IntoResponse, Response},
11};
12use docspec_json::{JsonEmitter, StrusonBackend};
13
14#[derive(Debug)]
20pub struct ProblemJson {
21 pub detail: Cow<'static, str>,
26 pub status: u16,
28 pub title: &'static str,
32 pub type_uri: &'static str,
36}
37
38const SERIALIZATION_FALLBACK_JSON: &[u8] = br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Error response serialization failed"}"#;
43
44impl ProblemJson {
45 fn try_to_json_bytes(&self) -> docspec_core::Result<Vec<u8>> {
46 let mut emitter = JsonEmitter::new(StrusonBackend::new(Vec::new()));
47 emitter.object(|builder| {
48 builder.key("type").value(self.type_uri)?;
49 builder.key("title").value(self.title)?;
50 builder.key("status").value(u32::from(self.status))?;
51 builder.key("detail").value(self.detail.as_ref())?;
52 Ok(())
53 })?;
54 emitter.finish()
55 }
56
57 #[inline]
69 #[must_use]
70 pub fn to_json_bytes(&self) -> Vec<u8> {
71 Self::bytes_or_fallback(self.try_to_json_bytes())
72 }
73
74 fn bytes_or_fallback(serialized: docspec_core::Result<Vec<u8>>) -> Vec<u8> {
76 serialized.unwrap_or_else(|_| SERIALIZATION_FALLBACK_JSON.to_vec())
77 }
78}
79
80fn problem_response(
82 problem: &ProblemJson,
83 status: StatusCode,
84 allow: Option<&'static str>,
85) -> Response {
86 problem_response_from(problem.try_to_json_bytes(), status, allow)
87}
88
89fn problem_response_from(
95 serialized: docspec_core::Result<Vec<u8>>,
96 status: StatusCode,
97 allow: Option<&'static str>,
98) -> Response {
99 let mut response = match serialized {
100 Ok(body) => {
101 let mut ok_response = (status, body).into_response();
102 if let Some(allowed) = allow {
103 ok_response
104 .headers_mut()
105 .insert(ALLOW, HeaderValue::from_static(allowed));
106 }
107 ok_response
108 }
109 Err(error) => {
110 tracing::error!(%error, "problem JSON serialization failed; sending static fallback");
111 (
112 StatusCode::INTERNAL_SERVER_ERROR,
113 SERIALIZATION_FALLBACK_JSON,
114 )
115 .into_response()
116 }
117 };
118 response.headers_mut().insert(
119 CONTENT_TYPE,
120 HeaderValue::from_static("application/problem+json; charset=utf-8"),
121 );
122 response
123}
124
125#[derive(Debug)]
130#[non_exhaustive]
131pub enum HttpError {
132 BodyNotUtf8,
136 EmptyBody,
140 Internal {
150 source: Box<dyn core::error::Error + Send + Sync + 'static>,
152 },
153 MethodNotAllowed {
157 allowed: &'static str,
159 },
160 NotAcceptable,
164 NotFound {
168 method: String,
170 path: String,
172 },
173 Unprocessable {
177 detail: String,
179 },
180 UnsupportedMediaType {
184 received: Option<String>,
186 },
187}
188
189#[cfg(feature = "posthog")]
196fn build_posthog_error_event(detail: &str, sentry_event_id: Option<String>) -> posthog_rs::Event {
197 let distinct_id = uuid::Uuid::new_v4().to_string();
198 let mut event = posthog_rs::Event::new("$exception", &distinct_id);
199 drop(event.insert_prop("$exception_message", detail));
200 drop(event.insert_prop("$is_anonymous", true));
201 if let Some(sentry_id) = sentry_event_id {
202 drop(event.insert_prop("sentry_event_id", sentry_id));
203 }
204 event
205}
206
207#[cfg(feature = "sentry")]
208#[inline]
209fn capture_sentry(
210 detail: &str,
211 source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
212) -> sentry::types::Uuid {
213 source.map_or_else(
214 || sentry::capture_message(detail, sentry::Level::Error),
215 sentry::capture_error,
216 )
217}
218
219#[cfg(any(feature = "sentry", feature = "posthog"))]
220fn capture_error_telemetry(
221 status: StatusCode,
222 detail: &str,
223 source: Option<&(dyn core::error::Error + Send + Sync + 'static)>,
224) {
225 if status != StatusCode::INTERNAL_SERVER_ERROR && status != StatusCode::UNPROCESSABLE_ENTITY {
226 return;
227 }
228
229 #[cfg(not(feature = "sentry"))]
230 let _ = source;
231
232 #[cfg(all(feature = "sentry", not(feature = "posthog")))]
233 {
234 let _ = capture_sentry(detail, source);
235 }
236 #[cfg(all(feature = "sentry", feature = "posthog"))]
237 let sentry_id_string: Option<String> = Some(capture_sentry(detail, source).to_string());
238 #[cfg(all(not(feature = "sentry"), feature = "posthog"))]
239 let sentry_id_string: Option<String> = None;
240 #[cfg(feature = "posthog")]
241 {
242 let client_arc: Option<std::sync::Arc<posthog_rs::Client>> =
243 match crate::telemetry::posthog_client_slot().read() {
244 Ok(guard) => guard.as_ref().map(std::sync::Arc::clone),
245 Err(_poisoned) => {
246 tracing::warn!("posthog slot poisoned; skipping error capture");
247 None
248 }
249 };
250 if let Some(client) = client_arc {
251 let event = build_posthog_error_event(detail, sentry_id_string);
252 client.capture(event);
253 }
254 }
255}
256
257impl IntoResponse for HttpError {
258 #[inline]
264 fn into_response(self) -> Response {
265 #[cfg(any(feature = "sentry", feature = "posthog"))]
266 self.capture_to_telemetry();
267
268 let (status, title, detail, allow): (
269 StatusCode,
270 &'static str,
271 Cow<'static, str>,
272 Option<&'static str>,
273 ) = match self {
274 Self::EmptyBody => (
275 StatusCode::BAD_REQUEST,
276 "Bad Request",
277 Cow::Borrowed("Request body is empty"),
278 None,
279 ),
280 Self::BodyNotUtf8 => (
281 StatusCode::BAD_REQUEST,
282 "Bad Request",
283 Cow::Borrowed("Request body is not valid UTF-8"),
284 None,
285 ),
286 Self::NotFound { method, path } => (
287 StatusCode::NOT_FOUND,
288 "Not Found",
289 Cow::Owned(format!("No route matches {method} {path}")),
290 None,
291 ),
292 Self::MethodNotAllowed { allowed } => (
293 StatusCode::METHOD_NOT_ALLOWED,
294 "Method Not Allowed",
295 Cow::Owned(format!("Method not allowed. Allowed methods: {allowed}.")),
296 Some(allowed),
297 ),
298 Self::NotAcceptable => (
299 StatusCode::NOT_ACCEPTABLE,
300 "Not Acceptable",
301 Cow::Borrowed(
302 "Accept header must include application/vnd.docspec.blocknote+json, \
303 application/vnd.blocknote+json, application/vnd.oxa+json, \
304 application/vnd.pandoc.native, text/html, application/*, or */*",
305 ),
306 None,
307 ),
308 Self::UnsupportedMediaType { received: None } => (
309 StatusCode::UNSUPPORTED_MEDIA_TYPE,
310 "Unsupported Media Type",
311 Cow::Borrowed("Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
312 None,
313 ),
314 Self::UnsupportedMediaType {
315 received: Some(content_type),
316 } => (
317 StatusCode::UNSUPPORTED_MEDIA_TYPE,
318 "Unsupported Media Type",
319 Cow::Owned(format!(
320 "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got {content_type}"
321 )),
322 None,
323 ),
324 Self::Unprocessable { detail } => (
325 StatusCode::UNPROCESSABLE_ENTITY,
326 "Unprocessable Entity",
327 Cow::Owned(detail),
328 None,
329 ),
330 Self::Internal { .. } => (
331 StatusCode::INTERNAL_SERVER_ERROR,
332 "Internal Server Error",
333 Cow::Borrowed("An unexpected error occurred during conversion"),
334 None,
335 ),
336 };
337
338 let problem = ProblemJson {
339 detail,
340 status: status.as_u16(),
341 title,
342 type_uri: "about:blank",
343 };
344 problem_response(&problem, status, allow)
345 }
346}
347
348impl HttpError {
349 #[inline]
352 #[must_use]
353 pub fn error_class(&self) -> &'static str {
354 match self {
355 Self::BodyNotUtf8 => "body_not_utf8",
356 Self::EmptyBody => "empty_body",
357 Self::Internal { .. } => "internal",
358 Self::MethodNotAllowed { .. } => "method_not_allowed",
359 Self::NotAcceptable => "not_acceptable",
360 Self::NotFound { .. } => "not_found",
361 Self::Unprocessable { .. } => "unprocessable",
362 Self::UnsupportedMediaType { .. } => "unsupported_media_type",
363 }
364 }
365
366 #[inline]
368 #[must_use]
369 pub fn result_class(&self) -> &'static str {
370 use crate::metrics::{RESULT_CLIENT_ERROR, RESULT_SERVER_ERROR};
371 match self {
372 Self::BodyNotUtf8
373 | Self::EmptyBody
374 | Self::MethodNotAllowed { .. }
375 | Self::NotAcceptable
376 | Self::NotFound { .. }
377 | Self::Unprocessable { .. }
378 | Self::UnsupportedMediaType { .. } => RESULT_CLIENT_ERROR,
379 Self::Internal { .. } => RESULT_SERVER_ERROR,
380 }
381 }
382
383 #[inline]
389 #[must_use]
390 pub fn internal<E>(source: E) -> Self
391 where
392 E: core::error::Error + Send + Sync + 'static,
393 {
394 Self::Internal {
395 source: Box::new(source),
396 }
397 }
398
399 #[cfg(any(feature = "sentry", feature = "posthog"))]
402 pub(crate) fn capture_to_telemetry(&self) {
403 match self {
404 Self::Internal { source } => {
405 capture_error_telemetry(
406 StatusCode::INTERNAL_SERVER_ERROR,
407 "An unexpected error occurred during conversion",
408 Some(source.as_ref()),
409 );
410 }
411 Self::Unprocessable { detail } => {
412 capture_error_telemetry(StatusCode::UNPROCESSABLE_ENTITY, detail.as_str(), None);
413 }
414 Self::BodyNotUtf8
415 | Self::EmptyBody
416 | Self::MethodNotAllowed { .. }
417 | Self::NotAcceptable
418 | Self::NotFound { .. }
419 | Self::UnsupportedMediaType { .. } => {}
420 }
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
429
430 use axum::{
431 http::{
432 header::{ALLOW, CONTENT_TYPE},
433 StatusCode,
434 },
435 response::IntoResponse as _,
436 };
437
438 use super::*;
439
440 async fn body_bytes(error: HttpError) -> Vec<u8> {
441 axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
442 .await
443 .unwrap()
444 .to_vec()
445 }
446
447 fn dummy_error() -> std::io::Error {
448 std::io::Error::other("dummy internal error")
449 }
450
451 fn serialization_error() -> docspec_core::Error {
452 docspec_core::Error::Other {
453 message: "forced serialization failure".to_string(),
454 }
455 }
456
457 #[test]
458 fn bytes_or_fallback_returns_static_body_when_serialization_fails() {
459 assert_eq!(
460 ProblemJson::bytes_or_fallback(Err(serialization_error())),
461 br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Error response serialization failed"}"#
462 .to_vec()
463 );
464 }
465
466 #[test]
467 fn bytes_or_fallback_passes_through_successful_serialization() {
468 assert_eq!(
469 ProblemJson::bytes_or_fallback(Ok(b"{\"ok\":true}".to_vec())),
470 b"{\"ok\":true}".to_vec()
471 );
472 }
473
474 #[tokio::test]
475 async fn problem_response_degrades_to_500_fallback_when_serialization_fails() {
476 let response =
477 problem_response_from(Err(serialization_error()), StatusCode::BAD_REQUEST, None);
478
479 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
480 assert_eq!(
481 response.headers().get(CONTENT_TYPE).unwrap(),
482 "application/problem+json; charset=utf-8"
483 );
484 assert_eq!(
485 axum::body::to_bytes(response.into_body(), usize::MAX)
486 .await
487 .unwrap()
488 .to_vec(),
489 br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Error response serialization failed"}"#
490 .to_vec()
491 );
492 }
493
494 #[tokio::test]
495 async fn problem_response_drops_allow_header_on_serialization_failure() {
496 let response = problem_response_from(
497 Err(serialization_error()),
498 StatusCode::METHOD_NOT_ALLOWED,
499 Some("POST, OPTIONS"),
500 );
501
502 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
503 assert_eq!(response.headers().get(ALLOW), None);
504 }
505
506 #[test]
507 fn all_variants_have_correct_status_codes() {
508 assert_eq!(
509 HttpError::EmptyBody.into_response().status(),
510 StatusCode::BAD_REQUEST
511 );
512 assert_eq!(
513 HttpError::BodyNotUtf8.into_response().status(),
514 StatusCode::BAD_REQUEST
515 );
516 assert_eq!(
517 HttpError::NotFound {
518 method: "GET".to_owned(),
519 path: "/foo".to_owned()
520 }
521 .into_response()
522 .status(),
523 StatusCode::NOT_FOUND
524 );
525 assert_eq!(
526 HttpError::MethodNotAllowed { allowed: "GET" }
527 .into_response()
528 .status(),
529 StatusCode::METHOD_NOT_ALLOWED
530 );
531 assert_eq!(
532 HttpError::NotAcceptable.into_response().status(),
533 StatusCode::NOT_ACCEPTABLE
534 );
535 assert_eq!(
536 HttpError::UnsupportedMediaType { received: None }
537 .into_response()
538 .status(),
539 StatusCode::UNSUPPORTED_MEDIA_TYPE
540 );
541 assert_eq!(
542 HttpError::Unprocessable {
543 detail: "bad".to_owned()
544 }
545 .into_response()
546 .status(),
547 StatusCode::UNPROCESSABLE_ENTITY
548 );
549 assert_eq!(
550 HttpError::internal(dummy_error()).into_response().status(),
551 StatusCode::INTERNAL_SERVER_ERROR
552 );
553 }
554
555 #[test]
556 fn method_not_allowed_has_allow_header() {
557 let response = HttpError::MethodNotAllowed { allowed: "GET" }.into_response();
558 let allow_val = response.headers().get(ALLOW).unwrap();
559 assert_eq!(allow_val, "GET");
560 }
561
562 #[test]
563 fn content_type_is_problem_json() {
564 let response = HttpError::internal(dummy_error()).into_response();
565 let content_type = response.headers().get(CONTENT_TYPE).unwrap();
566 assert_eq!(content_type, "application/problem+json; charset=utf-8");
567 }
568
569 #[test]
570 fn no_allow_header_on_non_405_variants() {
571 let response = HttpError::internal(dummy_error()).into_response();
572 assert!(response.headers().get(ALLOW).is_none());
573 }
574
575 #[cfg(feature = "sentry")]
576 #[test]
577 fn internal_error_is_captured_by_sentry_with_source_chain() {
578 let events = sentry::test::with_captured_events(|| {
579 let _response = HttpError::internal(dummy_error()).into_response();
580 });
581 assert_eq!(events.len(), 1);
582 assert_eq!(events[0].level, sentry::Level::Error);
583 assert!(
584 events[0].message.is_none(),
585 "capture_error must not populate top-level message"
586 );
587 let first_exception = events[0]
588 .exception
589 .values
590 .first()
591 .expect("exception chain must be populated by capture_error");
592 assert_eq!(
593 first_exception.value.as_deref(),
594 Some("dummy internal error"),
595 "exception value must equal the Display of the source error"
596 );
597 }
598
599 #[cfg(feature = "sentry")]
600 #[test]
601 fn unprocessable_error_is_captured_by_sentry() {
602 let events = sentry::test::with_captured_events(|| {
603 let _response = HttpError::Unprocessable {
604 detail: "bad input".to_owned(),
605 }
606 .into_response();
607 });
608 assert_eq!(events.len(), 1);
609 assert_eq!(events[0].level, sentry::Level::Error);
610 assert_eq!(events[0].message.as_deref(), Some("bad input"));
611 }
612
613 #[cfg(feature = "sentry")]
614 #[test]
615 fn client_errors_are_not_captured_by_sentry() {
616 let events = sentry::test::with_captured_events(|| {
617 drop(HttpError::EmptyBody.into_response());
618 drop(HttpError::BodyNotUtf8.into_response());
619 drop(
620 HttpError::NotFound {
621 method: "GET".to_owned(),
622 path: "/x".to_owned(),
623 }
624 .into_response(),
625 );
626 drop(HttpError::MethodNotAllowed { allowed: "GET" }.into_response());
627 drop(HttpError::NotAcceptable.into_response());
628 drop(HttpError::UnsupportedMediaType { received: None }.into_response());
629 });
630 assert_eq!(events.len(), 0, "4xx errors must not be captured");
631 }
632
633 #[tokio::test]
634 async fn serializes_with_four_fields() {
635 let bytes = body_bytes(HttpError::internal(dummy_error())).await;
636 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
637 assert_eq!(
638 json,
639 serde_json::json!({
640 "type": "about:blank",
641 "title": "Internal Server Error",
642 "status": 500,
643 "detail": "An unexpected error occurred during conversion",
644 })
645 );
646 }
647
648 #[tokio::test]
649 async fn no_instance_key_in_output() {
650 let bytes = body_bytes(HttpError::EmptyBody).await;
651 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
652 assert!(
653 json.get("instance").is_none(),
654 "unexpected 'instance' key in output"
655 );
656 }
657
658 #[tokio::test]
659 async fn not_found_problem_body_is_exact() {
660 let bytes = body_bytes(HttpError::NotFound {
661 method: "GET".to_owned(),
662 path: "/api/v99".to_owned(),
663 })
664 .await;
665 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
666 assert_eq!(
667 json,
668 serde_json::json!({
669 "type": "about:blank",
670 "title": "Not Found",
671 "status": 404,
672 "detail": "No route matches GET /api/v99",
673 })
674 );
675 }
676
677 #[tokio::test]
678 async fn internal_detail_is_fixed() {
679 let bytes = body_bytes(HttpError::internal(dummy_error())).await;
680 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
681 assert_eq!(
682 json["detail"].as_str().unwrap(),
683 "An unexpected error occurred during conversion"
684 );
685 }
686
687 #[tokio::test]
688 async fn unsupported_media_type_with_received_problem_body_is_exact() {
689 let bytes = body_bytes(HttpError::UnsupportedMediaType {
690 received: Some("application/json".to_owned()),
691 })
692 .await;
693 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
694 assert_eq!(
695 json,
696 serde_json::json!({
697 "type": "about:blank",
698 "title": "Unsupported Media Type",
699 "status": 415,
700 "detail": "Content-Type must be text/markdown, text/html, or application/vnd.openxmlformats-officedocument.wordprocessingml.document, got application/json",
701 })
702 );
703 }
704
705 #[tokio::test]
706 async fn unprocessable_problem_body_is_exact() {
707 let message = "heading level jumped from 1 to 3".to_owned();
708 let bytes = body_bytes(HttpError::Unprocessable {
709 detail: message.clone(),
710 })
711 .await;
712 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
713 assert_eq!(
714 json,
715 serde_json::json!({
716 "type": "about:blank",
717 "title": "Unprocessable Entity",
718 "status": 422,
719 "detail": message,
720 })
721 );
722 }
723
724 #[tokio::test]
725 async fn control_char_in_detail_is_escaped() {
726 let bytes = body_bytes(HttpError::Unprocessable {
727 detail: "bad\x01input".to_owned(),
728 })
729 .await;
730 assert_eq!(
731 bytes.as_slice(),
732 br#"{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"bad\u0001input"}"#
733 );
734 }
735
736 #[test]
737 fn body_not_utf8_error_class_returns_body_not_utf8() {
738 assert_eq!(HttpError::BodyNotUtf8.error_class(), "body_not_utf8");
739 }
740
741 #[test]
742 fn empty_body_error_class_returns_empty_body() {
743 assert_eq!(HttpError::EmptyBody.error_class(), "empty_body");
744 }
745
746 #[test]
747 fn internal_error_class_returns_internal() {
748 assert_eq!(HttpError::internal(dummy_error()).error_class(), "internal");
749 }
750
751 #[test]
752 fn method_not_allowed_error_class_returns_method_not_allowed() {
753 assert_eq!(
754 HttpError::MethodNotAllowed { allowed: "GET" }.error_class(),
755 "method_not_allowed"
756 );
757 }
758
759 #[test]
760 fn not_acceptable_error_class_returns_not_acceptable() {
761 assert_eq!(HttpError::NotAcceptable.error_class(), "not_acceptable");
762 }
763
764 #[test]
765 fn not_found_error_class_returns_not_found() {
766 assert_eq!(
767 HttpError::NotFound {
768 method: "GET".to_owned(),
769 path: "/foo".to_owned()
770 }
771 .error_class(),
772 "not_found"
773 );
774 }
775
776 #[test]
777 fn unprocessable_error_class_returns_unprocessable() {
778 assert_eq!(
779 HttpError::Unprocessable {
780 detail: "bad".to_owned()
781 }
782 .error_class(),
783 "unprocessable"
784 );
785 }
786
787 #[test]
788 fn unsupported_media_type_error_class_returns_unsupported_media_type() {
789 assert_eq!(
790 HttpError::UnsupportedMediaType { received: None }.error_class(),
791 "unsupported_media_type"
792 );
793 }
794
795 #[test]
796 fn body_not_utf8_result_class_returns_client_error() {
797 assert_eq!(HttpError::BodyNotUtf8.result_class(), "client_error");
798 }
799
800 #[test]
801 fn empty_body_result_class_returns_client_error() {
802 assert_eq!(HttpError::EmptyBody.result_class(), "client_error");
803 }
804
805 #[test]
806 fn internal_result_class_returns_server_error() {
807 assert_eq!(
808 HttpError::internal(dummy_error()).result_class(),
809 "server_error"
810 );
811 }
812
813 #[test]
814 fn method_not_allowed_result_class_returns_client_error() {
815 assert_eq!(
816 HttpError::MethodNotAllowed { allowed: "GET" }.result_class(),
817 "client_error"
818 );
819 }
820
821 #[test]
822 fn not_acceptable_result_class_returns_client_error() {
823 assert_eq!(HttpError::NotAcceptable.result_class(), "client_error");
824 }
825
826 #[test]
827 fn not_found_result_class_returns_client_error() {
828 assert_eq!(
829 HttpError::NotFound {
830 method: "GET".to_owned(),
831 path: "/foo".to_owned()
832 }
833 .result_class(),
834 "client_error"
835 );
836 }
837
838 #[test]
839 fn unprocessable_result_class_returns_client_error() {
840 assert_eq!(
841 HttpError::Unprocessable {
842 detail: "bad".to_owned()
843 }
844 .result_class(),
845 "client_error"
846 );
847 }
848
849 #[test]
850 fn unsupported_media_type_result_class_returns_client_error() {
851 assert_eq!(
852 HttpError::UnsupportedMediaType { received: None }.result_class(),
853 "client_error"
854 );
855 }
856
857 #[cfg(feature = "posthog")]
858 mod posthog_tests {
859 #![allow(clippy::expect_used, clippy::unwrap_used)]
860 use super::super::HttpError;
861 use super::dummy_error;
862 use axum::response::IntoResponse as _;
863
864 #[cfg(all(feature = "sentry", feature = "posthog"))]
865 #[test]
866 fn internal_error_into_response_does_not_panic_with_both_features() {
867 let response = HttpError::internal(dummy_error()).into_response();
868 assert_eq!(
869 response.status(),
870 axum::http::StatusCode::INTERNAL_SERVER_ERROR
871 );
872 }
873
874 #[cfg(all(feature = "posthog", not(feature = "sentry")))]
875 #[test]
876 fn internal_error_into_response_does_not_panic_with_posthog_only() {
877 let response = HttpError::internal(dummy_error()).into_response();
878 assert_eq!(
879 response.status(),
880 axum::http::StatusCode::INTERNAL_SERVER_ERROR
881 );
882 }
883
884 #[test]
885 fn client_error_returns_correct_status_and_posthog_block_is_bypassed() {
886 let response = HttpError::NotFound {
887 method: "GET".to_owned(),
888 path: "/foo".to_owned(),
889 }
890 .into_response();
891 assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
892 }
893
894 #[test]
895 fn into_response_outside_tokio_runtime_does_not_panic() {
896 let response = HttpError::internal(dummy_error()).into_response();
897 assert_eq!(
898 response.status(),
899 axum::http::StatusCode::INTERNAL_SERVER_ERROR
900 );
901 }
902 }
903}