Skip to main content

docspec_http/
error.rs

1//! Error types for the HTTP server.
2
3use 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/// RFC 7807 Problem Details for HTTP APIs.
15///
16/// Contains exactly four fields per [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807):
17/// `type`, `title`, `status`, and `detail`. Serialized without `serde`
18/// via [`ProblemJson::to_json_bytes`].
19#[derive(Debug)]
20pub struct ProblemJson {
21    /// Human-readable explanation specific to this occurrence.
22    ///
23    /// May contain user-supplied data such as path names or MIME type strings.
24    /// Dynamic values are stored as [`Cow::Owned`]; static strings as [`Cow::Borrowed`].
25    pub detail: Cow<'static, str>,
26    /// HTTP status code generated by this occurrence.
27    pub status: u16,
28    /// Short, human-readable summary of the problem type.
29    ///
30    /// Matches the standard HTTP reason phrase for the status code.
31    pub title: &'static str,
32    /// URI reference identifying the problem type.
33    ///
34    /// This server always uses `"about:blank"`.
35    pub type_uri: &'static str,
36}
37
38/// Pre-encoded RFC 7807 body used when serializing a `ProblemJson` fails.
39///
40/// Written out verbatim so the fallback never re-enters the JSON emitter that
41/// just failed.
42const 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    /// Serialize this problem detail as a JSON-encoded byte vector.
58    ///
59    /// Emits exactly four fields in document order:
60    /// `"type"`, `"title"`, `"status"`, `"detail"`. String fields are
61    /// JSON-escaped (RFC 8259 §7) by the underlying writer; the status is
62    /// an unquoted integer.
63    ///
64    /// Uses [`JsonEmitter`] backed by `struson` for serialization. If
65    /// serialization fails, returns a static RFC 7807 `500` body rather than
66    /// panicking, so constructing an error response can never itself abort the
67    /// request.
68    #[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    /// Unwraps a serialization result, degrading to the static fallback body.
75    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
80/// Builds the RFC 7807 response, degrading to a static body if serialization fails.
81fn 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
89/// Builds the RFC 7807 response from an already-attempted serialization.
90///
91/// Split from [`problem_response`] so the degraded path can be exercised
92/// directly: with a `Vec<u8>` sink the emitter never fails in practice, so the
93/// `Err` arm is otherwise unreachable from a test.
94fn 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/// HTTP-layer errors returned by the conversion API.
126///
127/// Each variant maps to a specific HTTP status code and is serialized as
128/// an RFC 7807 Problem JSON body via [`IntoResponse`].
129#[derive(Debug)]
130#[non_exhaustive]
131pub enum HttpError {
132    /// The request body bytes are not valid UTF-8.
133    ///
134    /// → HTTP 400 Bad Request.
135    BodyNotUtf8,
136    /// The request body was empty (`Content-Length: 0` or no body).
137    ///
138    /// → HTTP 400 Bad Request.
139    EmptyBody,
140    /// An unexpected internal error occurred during conversion.
141    ///
142    /// The `source` error is captured for observability (sent to Sentry via
143    /// [`sentry::capture_error`], logged at `warn!` in stderr) and drives the
144    /// error chain visible to operators. It is **never** exposed to the HTTP
145    /// client — the RFC 7807 response detail is always the generic
146    /// "An unexpected error occurred during conversion" string.
147    ///
148    /// → HTTP 500 Internal Server Error.
149    Internal {
150        /// The underlying error that caused this internal failure.
151        source: Box<dyn core::error::Error + Send + Sync + 'static>,
152    },
153    /// The HTTP method is not supported on this endpoint.
154    ///
155    /// → HTTP 405 Method Not Allowed (response includes an `Allow` header).
156    MethodNotAllowed {
157        /// Comma-separated list of allowed methods for this endpoint.
158        allowed: &'static str,
159    },
160    /// The `Accept` header excludes all formats this server produces.
161    ///
162    /// → HTTP 406 Not Acceptable.
163    NotAcceptable,
164    /// No route matches the requested method + path.
165    ///
166    /// → HTTP 404 Not Found.
167    NotFound {
168        /// The HTTP method of the unmatched request (e.g. `"GET"`).
169        method: String,
170        /// The path of the unmatched request (e.g. `"/unknown"`).
171        path: String,
172    },
173    /// Document conversion failed due to invalid or malformed input.
174    ///
175    /// → HTTP 422 Unprocessable Entity.
176    Unprocessable {
177        /// Explanation of what made the input invalid.
178        detail: String,
179    },
180    /// The `Content-Type` header is not one of the supported types: `text/markdown`, `text/html`, or `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
181    ///
182    /// → HTTP 415 Unsupported Media Type.
183    UnsupportedMediaType {
184        /// The content-type that was received, if any.
185        received: Option<String>,
186    },
187}
188
189/// Constructs a `PostHog` `$exception` event for an HTTP error.
190///
191/// The `sentry_event_id` property correlates this event with the corresponding
192/// Sentry event when both backends are active. Each call uses a fresh `distinct_id`
193/// (per-event UUID with `$is_anonymous = true`) because the sync `IntoResponse`
194/// context does not have access to the request ID.
195#[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    /// Convert this error into an HTTP response with an RFC 7807 Problem JSON body.
259    ///
260    /// Sets `Content-Type: application/problem+json; charset=utf-8` and a JSON body
261    /// with exactly four fields: `type`, `title`, `status`, `detail`.
262    /// [`HttpError::MethodNotAllowed`] additionally sets the `Allow` response header.
263    #[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    /// Returns a stable, low-cardinality string identifying the error class.
350    /// Safe to use as a Prometheus label value — never contains per-request data.
351    #[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    /// Returns the result class for Prometheus labels: `"client_error"` for 4xx, `"server_error"` for 5xx.
367    #[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    /// Wrap an unexpected internal error as [`HttpError::Internal`].
384    ///
385    /// The source is preserved end-to-end for observability — it flows to
386    /// Sentry via [`sentry::capture_error`] (walking the [`core::error::Error::source`]
387    /// chain) and is logged at `warn!` level via [`core::fmt::Debug`] formatting.
388    #[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    /// Capture this error to configured telemetry backends (Sentry, `PostHog`)
400    /// when the variant is a 500 or 422.
401    #[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    // Reason: test code legitimately panics on assertion failures; unwrap, expect,
427    // and slice indexing are standard testing patterns that express expected outcomes.
428    #![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}