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 mdcast_api::Digest;
11use mdcast_api::wire::{ErrorCode, ErrorResponse, MissingBlob};
12
13pub type Result<T> = std::result::Result<T, Error>;
14
15/// How long an unparseable error body may grow before it is truncated into an
16/// error message — a misconfigured proxy can answer with a whole HTML page.
17const SNIPPET_CHARS: usize = 512;
18
19#[derive(Debug, thiserror::Error)]
20pub enum Error {
21    /// The client was built with a base URL or token it cannot use.
22    #[error("invalid client configuration: {0}")]
23    Config(String),
24
25    /// The request never completed: DNS, connect, TLS, timeout, or a body that
26    /// could not be read to the end.
27    #[error("could not reach the mdcast server: {0}")]
28    Transport(#[from] reqwest::Error),
29
30    /// `401` — the bearer token is missing or wrong. The remedy is credentials,
31    /// not payload, which is why it is not folded into [`Error::Server`].
32    #[error("the mdcast server rejected the bearer token: {message}")]
33    Unauthorized { message: String },
34
35    /// `413` — the body exceeded the server's `MDCAST_MAX_UPLOAD_BYTES`.
36    /// Retrying the identical request is hopeless; the caller has to shrink or
37    /// split it.
38    #[error("the mdcast server refused the request body as too large: {message}")]
39    PayloadTooLarge { message: String },
40
41    /// A second `409` for the same render after the missing blobs were
42    /// uploaded. The client retries exactly once — the same digests coming
43    /// back means eviction is racing the retry or the server is misbehaving,
44    /// and looping would only hide it.
45    #[error(
46        "the mdcast server still reports {} blob(s) missing after uploading them and retrying once",
47        .missing.len()
48    )]
49    BlobsStillMissing { missing: Vec<MissingBlob> },
50
51    /// The server answered with its own [`ErrorResponse`] — the code and the
52    /// message are carried through verbatim.
53    #[error("the mdcast server returned {status} ({code:?}): {message}")]
54    Server {
55        status: u16,
56        code: ErrorCode,
57        message: String,
58    },
59
60    /// A status this protocol does not define, or one whose body is not an
61    /// `ErrorResponse` (a proxy or load balancer answering instead of mdcast).
62    #[error("unexpected {status} response from the mdcast server: {body}")]
63    UnexpectedStatus { status: u16, body: String },
64
65    /// The status was one the protocol defines, but its body did not decode.
66    #[error("could not decode the mdcast server's {status} response: {reason}")]
67    InvalidResponse { status: u16, reason: String },
68
69    /// A manifest entry the bundle holds no bytes for. Caught locally: a `409`
70    /// naming it would only tell the caller a digest it cannot resolve.
71    #[error("asset key `{key}` is named by the manifest but the bundle holds no bytes for it")]
72    MissingAsset { key: String },
73
74    /// A digest-only bundle entry's `fetch` closure failed to produce bytes at
75    /// all, while satisfying an upload the server actually asked for. Never a
76    /// silent skip — the retry would just `409` on the same digest again.
77    #[error("fetching asset `{key}` failed: {source}")]
78    BlobFetchFailed {
79        key: String,
80        #[source]
81        source: crate::bundle::BlobFetchError,
82    },
83
84    /// A digest asked for by hand (via [`crate::Client::upload`]) that nothing
85    /// in the bundle hashes to.
86    #[error("no asset in the bundle hashes to {digest}")]
87    UnknownDigest { digest: Digest },
88
89    /// A caller-supplied manifest that disagrees with the bundle's actual
90    /// bytes. The upload would be rejected server-side anyway — the server
91    /// re-hashes every byte — so it fails before the round trip. Also raised
92    /// when a digest-only entry's `fetch` succeeds but the bytes it returns
93    /// hash to something other than the digest it was declared with.
94    #[error("asset key `{key}` is declared as {declared} but its bundle bytes hash to {actual}")]
95    DigestMismatch {
96        key: String,
97        declared: Digest,
98        actual: Digest,
99    },
100}
101
102/// Project a non-success, non-`409` response onto the typed error. `401` and
103/// `413` keep their identity even when the body is not an `ErrorResponse`,
104/// because an intermediary can answer either one with its own page.
105pub(crate) fn from_status(status: u16, body: &[u8]) -> Error {
106    let parsed = serde_json::from_slice::<ErrorResponse>(body).ok();
107    match status {
108        401 => Error::Unauthorized {
109            message: message_of(parsed, body),
110        },
111        413 => Error::PayloadTooLarge {
112            message: message_of(parsed, body),
113        },
114        _ => match parsed {
115            Some(ErrorResponse { code, message }) => Error::Server {
116                status,
117                code,
118                message,
119            },
120            None => Error::UnexpectedStatus {
121                status,
122                body: snippet(body),
123            },
124        },
125    }
126}
127
128fn message_of(parsed: Option<ErrorResponse>, body: &[u8]) -> String {
129    parsed.map(|e| e.message).unwrap_or_else(|| snippet(body))
130}
131
132fn snippet(body: &[u8]) -> String {
133    let text = String::from_utf8_lossy(body);
134    let text = text.trim();
135    if text.chars().count() <= SNIPPET_CHARS {
136        return text.to_string();
137    }
138    let head: String = text.chars().take(SNIPPET_CHARS).collect();
139    format!("{head}…")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn body(code: &str, message: &str) -> Vec<u8> {
147        format!(r#"{{"code":"{code}","message":"{message}"}}"#).into_bytes()
148    }
149
150    #[test]
151    fn unauthorized_and_too_large_get_their_own_variants() {
152        assert!(matches!(
153            from_status(401, &body("unauthorized", "missing or invalid bearer token")),
154            Error::Unauthorized { message } if message.contains("bearer token")
155        ));
156        assert!(matches!(
157            from_status(413, &body("payload_too_large", "too big")),
158            Error::PayloadTooLarge { message } if message == "too big"
159        ));
160    }
161
162    #[test]
163    fn an_intermediarys_bodyless_401_is_still_unauthorized() {
164        assert!(matches!(
165            from_status(401, b"<html>Forbidden by the proxy</html>"),
166            Error::Unauthorized { message } if message.contains("proxy")
167        ));
168    }
169
170    #[test]
171    fn a_server_error_response_carries_its_code_through() {
172        let err = from_status(422, &body("render_failed", "render failed (incident 7-0)"));
173
174        match err {
175            Error::Server {
176                status,
177                code,
178                message,
179            } => {
180                assert_eq!(status, 422);
181                assert_eq!(code, ErrorCode::RenderFailed);
182                assert!(message.contains("incident 7-0"), "{message}");
183            }
184            other => panic!("expected Server, got {other:?}"),
185        }
186        assert!(matches!(
187            from_status(501, &body("unsupported_target", "no typst-html here")),
188            Error::Server {
189                code: ErrorCode::UnsupportedTarget,
190                ..
191            }
192        ));
193        assert!(matches!(
194            from_status(500, &body("internal", "internal error")),
195            Error::Server {
196                code: ErrorCode::Internal,
197                ..
198            }
199        ));
200    }
201
202    #[test]
203    fn a_body_that_is_not_an_error_response_is_unexpected() {
204        match from_status(502, b"<html>bad gateway</html>") {
205            Error::UnexpectedStatus { status, body } => {
206                assert_eq!(status, 502);
207                assert_eq!(body, "<html>bad gateway</html>");
208            }
209            other => panic!("expected UnexpectedStatus, got {other:?}"),
210        }
211    }
212
213    #[test]
214    fn an_oversized_body_is_truncated_on_a_char_boundary() {
215        let huge = "é".repeat(SNIPPET_CHARS * 2).into_bytes();
216
217        match from_status(502, &huge) {
218            Error::UnexpectedStatus { body, .. } => {
219                assert_eq!(body.chars().count(), SNIPPET_CHARS + 1, "{body}");
220                assert!(body.ends_with('…'));
221            }
222            other => panic!("expected UnexpectedStatus, got {other:?}"),
223        }
224    }
225}