Skip to main content

mdcast_client/
error.rs

1//! The one error type every call returns.
2//!
3//! The failures a caller actually branches on are separate variants — bad
4//! credentials, a body the server refused as too large, a `409` that survived
5//! the retry, a bundle that cannot satisfy its own manifest. Everything else
6//! the server classified itself arrives as [`Error::Server`], carrying the
7//! wire [`ErrorCode`] rather than a flattened string, so a caller can match on
8//! `RenderFailed` versus `Internal` without parsing prose.
9
10use std::time::Duration;
11
12use mdcast_api::Digest;
13use mdcast_api::wire::{ErrorCode, ErrorResponse, MissingBlob};
14
15pub type Result<T> = std::result::Result<T, Error>;
16
17/// How long an unparseable error body may grow before it is truncated into an
18/// error message — a misconfigured proxy can answer with a whole HTML page.
19const SNIPPET_CHARS: usize = 512;
20
21/// Non-exhaustive, like [`Error::Server`]'s fields: new failure modes and
22/// new server-supplied fields are not breaking changes, so a `match` needs a
23/// wildcard arm and a `Server { .. }` pattern a `..`.
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum Error {
27    /// The client was built with a base URL or token it cannot use.
28    #[error("invalid client configuration: {0}")]
29    Config(String),
30
31    /// The request never completed: DNS, connect, TLS, timeout, or a body that
32    /// could not be read to the end.
33    #[error("could not reach the mdcast server: {0}")]
34    Transport(#[from] reqwest::Error),
35
36    /// `401` — the bearer token is missing or wrong. The remedy is credentials,
37    /// not payload, which is why it is not folded into [`Error::Server`].
38    #[error("the mdcast server rejected the bearer token: {message}")]
39    Unauthorized { message: String },
40
41    /// `413` — the body exceeded the server's `MDCAST_MAX_UPLOAD_BYTES`.
42    /// Retrying the identical request is hopeless; the caller has to shrink or
43    /// split it.
44    #[error("the mdcast server refused the request body as too large: {message}")]
45    PayloadTooLarge { message: String },
46
47    /// `503` with code `overloaded` — every render slot stayed busy for the
48    /// server's whole queue wait. The request itself is fine: send it again
49    /// after `retry_after` (the server's `Retry-After`, when it sent one).
50    #[error("the mdcast server is overloaded: {message}")]
51    Overloaded {
52        message: String,
53        retry_after: Option<Duration>,
54    },
55
56    /// A second `409` for the same render after the missing blobs were
57    /// uploaded. The client retries exactly once — the same digests coming
58    /// back means eviction is racing the retry or the server is misbehaving,
59    /// and looping would only hide it.
60    #[error(
61        "the mdcast server still reports {} blob(s) missing after uploading them and retrying once",
62        .missing.len()
63    )]
64    BlobsStillMissing { missing: Vec<MissingBlob> },
65
66    /// The server answered with its own [`ErrorResponse`] — the code, the
67    /// message and any `detail` (sanitised engine diagnostics, sent only by a
68    /// server with `MDCAST_EXPOSE_DIAGNOSTICS` on) are carried through
69    /// verbatim.
70    #[error("the mdcast server returned {status} ({code:?}): {message}")]
71    #[non_exhaustive]
72    Server {
73        status: u16,
74        code: ErrorCode,
75        message: String,
76        detail: Vec<String>,
77    },
78
79    /// A status this protocol does not define, or one whose body is not an
80    /// `ErrorResponse` (a proxy or load balancer answering instead of mdcast).
81    #[error("unexpected {status} response from the mdcast server: {body}")]
82    UnexpectedStatus { status: u16, body: String },
83
84    /// The status was one the protocol defines, but its body did not decode.
85    #[error("could not decode the mdcast server's {status} response: {reason}")]
86    InvalidResponse { status: u16, reason: String },
87
88    /// A manifest entry the bundle holds no bytes for. Caught locally: a `409`
89    /// naming it would only tell the caller a digest it cannot resolve.
90    #[error("asset key `{key}` is named by the manifest but the bundle holds no bytes for it")]
91    MissingAsset { key: String },
92
93    /// A digest-only bundle entry's `fetch` closure failed to produce bytes at
94    /// all, while satisfying an upload the server actually asked for. Never a
95    /// silent skip — the retry would just `409` on the same digest again.
96    #[error("fetching asset `{key}` failed: {source}")]
97    BlobFetchFailed {
98        key: String,
99        #[source]
100        source: crate::bundle::BlobFetchError,
101    },
102
103    /// A digest asked for by hand (via [`crate::Client::upload`]) that nothing
104    /// in the bundle hashes to.
105    #[error("no asset in the bundle hashes to {digest}")]
106    UnknownDigest { digest: Digest },
107
108    /// A caller-supplied manifest that disagrees with the bundle's actual
109    /// bytes. The upload would be rejected server-side anyway — the server
110    /// re-hashes every byte — so it fails before the round trip. Also raised
111    /// when a digest-only entry's `fetch` succeeds but the bytes it returns
112    /// hash to something other than the digest it was declared with.
113    #[error("asset key `{key}` is declared as {declared} but its bundle bytes hash to {actual}")]
114    DigestMismatch {
115        key: String,
116        declared: Digest,
117        actual: Digest,
118    },
119}
120
121/// Project a non-success, non-`409` response onto the typed error. `401` and
122/// `413` keep their identity even when the body is not an `ErrorResponse`,
123/// because an intermediary can answer either one with its own page. A `503`
124/// is only [`Error::Overloaded`] when mdcast itself said so — a proxy's `503`
125/// means the server is unreachable, not busy.
126pub(crate) fn from_status(status: u16, retry_after: Option<Duration>, body: &[u8]) -> Error {
127    let parsed = serde_json::from_slice::<ErrorResponse>(body).ok();
128    match status {
129        401 => Error::Unauthorized {
130            message: message_of(parsed, body),
131        },
132        413 => Error::PayloadTooLarge {
133            message: message_of(parsed, body),
134        },
135        _ => match parsed {
136            Some(ErrorResponse {
137                code: ErrorCode::Overloaded,
138                message,
139                ..
140            }) if status == 503 => Error::Overloaded {
141                message,
142                retry_after,
143            },
144            Some(ErrorResponse {
145                code,
146                message,
147                detail,
148                ..
149            }) => Error::Server {
150                status,
151                code,
152                message,
153                detail,
154            },
155            None => Error::UnexpectedStatus {
156                status,
157                body: snippet(body),
158            },
159        },
160    }
161}
162
163fn message_of(parsed: Option<ErrorResponse>, body: &[u8]) -> String {
164    parsed.map(|e| e.message).unwrap_or_else(|| snippet(body))
165}
166
167fn snippet(body: &[u8]) -> String {
168    let text = String::from_utf8_lossy(body);
169    let text = text.trim();
170    if text.chars().count() <= SNIPPET_CHARS {
171        return text.to_string();
172    }
173    let head: String = text.chars().take(SNIPPET_CHARS).collect();
174    format!("{head}…")
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn body(code: &str, message: &str) -> Vec<u8> {
182        format!(r#"{{"code":"{code}","message":"{message}"}}"#).into_bytes()
183    }
184
185    #[test]
186    fn unauthorized_and_too_large_get_their_own_variants() {
187        assert!(matches!(
188            from_status(401, None, &body("unauthorized", "missing or invalid bearer token")),
189            Error::Unauthorized { message } if message.contains("bearer token")
190        ));
191        assert!(matches!(
192            from_status(413, None, &body("payload_too_large", "too big")),
193            Error::PayloadTooLarge { message } if message == "too big"
194        ));
195    }
196
197    #[test]
198    fn an_intermediarys_bodyless_401_is_still_unauthorized() {
199        assert!(matches!(
200            from_status(401, None, b"<html>Forbidden by the proxy</html>"),
201            Error::Unauthorized { message } if message.contains("proxy")
202        ));
203    }
204
205    #[test]
206    fn a_server_error_response_carries_its_code_through() {
207        let err = from_status(
208            422,
209            None,
210            &body("render_failed", "render failed (incident 7-0)"),
211        );
212
213        match err {
214            Error::Server {
215                status,
216                code,
217                message,
218                detail,
219            } => {
220                assert!(detail.is_empty());
221                assert_eq!(status, 422);
222                assert_eq!(code, ErrorCode::RenderFailed);
223                assert!(message.contains("incident 7-0"), "{message}");
224            }
225            other => panic!("expected Server, got {other:?}"),
226        }
227        assert!(matches!(
228            from_status(501, None, &body("unsupported_target", "no typst-html here")),
229            Error::Server {
230                code: ErrorCode::UnsupportedTarget,
231                ..
232            }
233        ));
234        assert!(matches!(
235            from_status(500, None, &body("internal", "internal error")),
236            Error::Server {
237                code: ErrorCode::Internal,
238                ..
239            }
240        ));
241    }
242
243    #[test]
244    fn a_body_that_is_not_an_error_response_is_unexpected() {
245        match from_status(502, None, b"<html>bad gateway</html>") {
246            Error::UnexpectedStatus { status, body } => {
247                assert_eq!(status, 502);
248                assert_eq!(body, "<html>bad gateway</html>");
249            }
250            other => panic!("expected UnexpectedStatus, got {other:?}"),
251        }
252    }
253
254    #[test]
255    fn an_oversized_body_is_truncated_on_a_char_boundary() {
256        let huge = "é".repeat(SNIPPET_CHARS * 2).into_bytes();
257
258        match from_status(502, None, &huge) {
259            Error::UnexpectedStatus { body, .. } => {
260                assert_eq!(body.chars().count(), SNIPPET_CHARS + 1, "{body}");
261                assert!(body.ends_with('…'));
262            }
263            other => panic!("expected UnexpectedStatus, got {other:?}"),
264        }
265    }
266
267    #[test]
268    fn an_overloaded_503_carries_its_retry_after() {
269        let err = from_status(
270            503,
271            Some(Duration::from_secs(7)),
272            &body("overloaded", "all render slots busy"),
273        );
274        assert!(matches!(
275            err,
276            Error::Overloaded { message, retry_after: Some(d) }
277                if message == "all render slots busy" && d == Duration::from_secs(7)
278        ));
279    }
280
281    #[test]
282    fn a_proxys_503_is_not_overloaded() {
283        assert!(matches!(
284            from_status(503, None, b"<html>no healthy upstream</html>"),
285            Error::UnexpectedStatus { status: 503, .. }
286        ));
287    }
288
289    #[test]
290    fn render_failed_detail_is_carried_through() {
291        let body = br#"{"code":"render_failed","message":"m","detail":["main.typ:1:1: error: x"]}"#;
292        assert!(matches!(
293            from_status(422, None, body),
294            Error::Server { detail, .. } if detail == ["main.typ:1:1: error: x"]
295        ));
296    }
297}