Skip to main content

docspec_http/
error.rs

1//! Error types for the HTTP server.
2
3// Reason: docspec-http is an HTTP server unconditionally requiring std;
4// alloc is not in the extern prelude for std crates without `extern crate alloc`.
5#![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/// RFC 7807 Problem Details for HTTP APIs.
19///
20/// Contains exactly four fields per [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807):
21/// `type`, `title`, `status`, and `detail`. Serialized without `serde`
22/// via [`ProblemJson::to_json_bytes`].
23#[derive(Debug)]
24pub struct ProblemJson {
25    /// Human-readable explanation specific to this occurrence.
26    ///
27    /// May contain user-supplied data such as path names or MIME type strings.
28    /// Dynamic values are stored as [`Cow::Owned`]; static strings as [`Cow::Borrowed`].
29    pub detail: Cow<'static, str>,
30    /// HTTP status code generated by this occurrence.
31    pub status: u16,
32    /// Short, human-readable summary of the problem type.
33    ///
34    /// Matches the standard HTTP reason phrase for the status code.
35    pub title: &'static str,
36    /// URI reference identifying the problem type.
37    ///
38    /// This server always uses `"about:blank"`.
39    pub type_uri: &'static str,
40}
41
42impl ProblemJson {
43    /// Serialize this problem detail as a JSON-encoded byte vector.
44    ///
45    /// Emits exactly four fields in document order:
46    /// `"type"`, `"title"`, `"status"`, `"detail"`. String fields are
47    /// JSON-escaped (RFC 8259 §7) by the underlying writer; the status is
48    /// an unquoted integer.
49    ///
50    /// Uses [`JsonEmitter`] backed by `struson` for serialization.
51    ///
52    /// # Panics
53    ///
54    /// Does not panic for any well-formed `ProblemJson` instance. The
55    /// internal `.expect()` calls would only trigger on a bug in
56    /// `docspec-json` (the key/value sequence is statically valid JSON and
57    /// the `Vec<u8>` writer is infallible).
58    #[inline]
59    #[must_use]
60    pub fn to_json_bytes(&self) -> Vec<u8> {
61        // Reason: emission of a fixed 4-field object into Vec<u8> cannot fail
62        // in practice — Vec writes are infallible and the key/value sequence
63        // is statically valid JSON. Any error here would indicate a bug in
64        // docspec-json itself, not runtime input.
65        #[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/// HTTP-layer errors returned by the conversion API.
83///
84/// Each variant maps to a specific HTTP status code and is serialized as
85/// an RFC 7807 Problem JSON body via [`IntoResponse`].
86#[derive(Debug)]
87#[non_exhaustive]
88pub enum HttpError {
89    /// The request body bytes are not valid UTF-8.
90    ///
91    /// → HTTP 400 Bad Request.
92    BodyNotUtf8,
93    /// The request body was empty (`Content-Length: 0` or no body).
94    ///
95    /// → HTTP 400 Bad Request.
96    EmptyBody,
97    /// An unexpected internal error occurred during conversion.
98    ///
99    /// The `source` error is captured for observability (sent to Sentry via
100    /// [`sentry::capture_error`], logged at `warn!` in stderr) and drives the
101    /// error chain visible to operators. It is **never** exposed to the HTTP
102    /// client — the RFC 7807 response detail is always the generic
103    /// "An unexpected error occurred during conversion" string.
104    ///
105    /// → HTTP 500 Internal Server Error.
106    Internal {
107        /// The underlying error that caused this internal failure.
108        source: Box<dyn core::error::Error + Send + Sync + 'static>,
109    },
110    /// The HTTP method is not supported on this endpoint.
111    ///
112    /// → HTTP 405 Method Not Allowed (response includes an `Allow` header).
113    MethodNotAllowed {
114        /// Comma-separated list of allowed methods for this endpoint.
115        allowed: &'static str,
116    },
117    /// The `Accept` header excludes all formats this server produces.
118    ///
119    /// → HTTP 406 Not Acceptable.
120    NotAcceptable,
121    /// No route matches the requested method + path.
122    ///
123    /// → HTTP 404 Not Found.
124    NotFound {
125        /// The HTTP method of the unmatched request (e.g. `"GET"`).
126        method: String,
127        /// The path of the unmatched request (e.g. `"/unknown"`).
128        path: String,
129    },
130    /// Document conversion failed due to invalid or malformed input.
131    ///
132    /// → HTTP 422 Unprocessable Entity.
133    Unprocessable {
134        /// Explanation of what made the input invalid.
135        detail: String,
136    },
137    /// The `Content-Type` header is not one of the supported types: `text/markdown`, `text/html`, or `application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
138    ///
139    /// → HTTP 415 Unsupported Media Type.
140    UnsupportedMediaType {
141        /// The content-type that was received, if any.
142        received: Option<String>,
143    },
144}
145
146/// Constructs a `PostHog` `$exception` event for an HTTP error.
147///
148/// The `sentry_event_id` property correlates this event with the corresponding
149/// Sentry event when both backends are active. Each call uses a fresh `distinct_id`
150/// (per-event UUID with `$is_anonymous = true`) because the sync `IntoResponse`
151/// context does not have access to the request ID.
152#[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    /// Convert this error into an HTTP response with an RFC 7807 Problem JSON body.
216    ///
217    /// Sets `Content-Type: application/problem+json; charset=utf-8` and a JSON body
218    /// with exactly four fields: `type`, `title`, `status`, `detail`.
219    /// [`HttpError::MethodNotAllowed`] additionally sets the `Allow` response header.
220    #[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    /// Returns a stable, low-cardinality string identifying the error class.
319    /// Safe to use as a Prometheus label value — never contains per-request data.
320    #[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    /// Returns the result class for Prometheus labels: `"client_error"` for 4xx, `"server_error"` for 5xx.
336    #[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    /// Wrap an unexpected internal error as [`HttpError::Internal`].
353    ///
354    /// The source is preserved end-to-end for observability — it flows to
355    /// Sentry via [`sentry::capture_error`] (walking the [`core::error::Error::source`]
356    /// chain) and is logged at `warn!` level via [`core::fmt::Debug`] formatting.
357    #[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    /// Capture this error to configured telemetry backends (Sentry, `PostHog`)
369    /// when the variant is a 500 or 422.
370    #[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    // Reason: test code legitimately panics on assertion failures; unwrap, expect,
396    // and slice indexing are standard testing patterns that express expected outcomes.
397    #![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}