Skip to main content

mdcast_api/wire/
error.rs

1//! The generic error body every 4xx/5xx other than `409 Conflict` carries.
2//! `409` has its own shape ([`crate::MissingBlobsError`]) because a client
3//! acts on it rather than reporting it.
4
5use serde::{Deserialize, Serialize};
6
7/// A failed request: something for a human (`message`) and something for the
8/// client to branch on (`code`). Non-exhaustive so a field can be added
9/// without a breaking release: build one with [`ErrorResponse::new`].
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[non_exhaustive]
12pub struct ErrorResponse {
13    pub code: ErrorCode,
14    pub message: String,
15    /// Sanitised engine diagnostics (`path:line:col: severity: message`) for a
16    /// `render_failed`, present only when the server opts in
17    /// (`MDCAST_EXPOSE_DIAGNOSTICS`). Omitted when empty, so a body without
18    /// it is byte-identical to one from a server that predates the field.
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub detail: Vec<String>,
21}
22
23impl ErrorResponse {
24    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
25        Self {
26            code,
27            message: message.into(),
28            detail: Vec::new(),
29        }
30    }
31
32    pub fn with_detail(mut self, detail: Vec<String>) -> Self {
33        self.detail = detail;
34        self
35    }
36}
37
38/// Machine-readable failure classification. Non-exhaustive: a newer server
39/// may add codes (an unknown one decodes as [`ErrorCode::Unknown`]), so a
40/// `match` needs a wildcard arm.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43#[non_exhaustive]
44pub enum ErrorCode {
45    /// The body did not parse, or a field is out of range.
46    BadRequest,
47    /// No bearer token, a malformed `Authorization` header, or the wrong
48    /// token. Distinct from [`ErrorCode::BadRequest`] because the client's
49    /// remedy is to fix its credentials, not its payload.
50    Unauthorized,
51    /// The request body is larger than the server accepts. Distinct from
52    /// [`ErrorCode::BadRequest`] because retrying the identical request is
53    /// hopeless — the client has to split or shrink the upload.
54    PayloadTooLarge,
55    /// The server was built without the feature this target/format needs
56    /// (e.g. `html` without `typst-html`), or lacks the `pandoc` binary.
57    UnsupportedTarget,
58    /// The pipeline ran and failed — bad markdown, a template that does not
59    /// compile, an engine error.
60    RenderFailed,
61    /// Anything the server cannot attribute to the request.
62    Internal,
63    /// Every render slot stayed busy for the server's whole queue wait
64    /// (`503`, with `Retry-After`). Nothing is wrong with the request; send
65    /// it again later.
66    Overloaded,
67    /// A code this version of the protocol does not know — what a newer
68    /// server's addition decodes to, instead of failing the whole body.
69    #[serde(other)]
70    Unknown,
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn error_response_round_trips_through_json() {
79        let err = ErrorResponse::new(ErrorCode::RenderFailed, "typst: unclosed delimiter");
80
81        let json = serde_json::to_string(&err).unwrap();
82        assert_eq!(
83            json,
84            r#"{"code":"render_failed","message":"typst: unclosed delimiter"}"#
85        );
86        assert_eq!(serde_json::from_str::<ErrorResponse>(&json).unwrap(), err);
87    }
88
89    #[test]
90    fn error_codes_serialize_snake_case() {
91        for (code, expected) in [
92            (ErrorCode::BadRequest, "\"bad_request\""),
93            (ErrorCode::Unauthorized, "\"unauthorized\""),
94            (ErrorCode::PayloadTooLarge, "\"payload_too_large\""),
95            (ErrorCode::UnsupportedTarget, "\"unsupported_target\""),
96            (ErrorCode::RenderFailed, "\"render_failed\""),
97            (ErrorCode::Internal, "\"internal\""),
98            (ErrorCode::Overloaded, "\"overloaded\""),
99        ] {
100            let json = serde_json::to_string(&code).unwrap();
101            assert_eq!(json, expected);
102            assert_eq!(serde_json::from_str::<ErrorCode>(&json).unwrap(), code);
103        }
104    }
105
106    #[test]
107    fn an_unknown_code_decodes_instead_of_failing_the_body() {
108        let err: ErrorResponse =
109            serde_json::from_str(r#"{"code":"from_the_future","message":"m"}"#).unwrap();
110        assert_eq!(err.code, ErrorCode::Unknown);
111    }
112
113    #[test]
114    fn detail_is_omitted_when_empty_and_carried_when_present() {
115        let with = ErrorResponse::new(ErrorCode::RenderFailed, "m")
116            .with_detail(vec!["main.typ:1:2: error: x".into()]);
117
118        let json = serde_json::to_string(&with).unwrap();
119        assert_eq!(
120            json,
121            r#"{"code":"render_failed","message":"m","detail":["main.typ:1:2: error: x"]}"#
122        );
123        assert_eq!(serde_json::from_str::<ErrorResponse>(&json).unwrap(), with);
124        let old: ErrorResponse =
125            serde_json::from_str(r#"{"code":"render_failed","message":"m"}"#).unwrap();
126        assert!(old.detail.is_empty());
127    }
128}