1use 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
17const SNIPPET_CHARS: usize = 512;
20
21#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum Error {
27 #[error("invalid client configuration: {0}")]
29 Config(String),
30
31 #[error("could not reach the mdcast server: {0}")]
34 Transport(#[from] reqwest::Error),
35
36 #[error("the mdcast server rejected the bearer token: {message}")]
39 Unauthorized { message: String },
40
41 #[error("the mdcast server refused the request body as too large: {message}")]
45 PayloadTooLarge { message: String },
46
47 #[error("the mdcast server is overloaded: {message}")]
51 Overloaded {
52 message: String,
53 retry_after: Option<Duration>,
54 },
55
56 #[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 #[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 #[error("unexpected {status} response from the mdcast server: {body}")]
82 UnexpectedStatus { status: u16, body: String },
83
84 #[error("could not decode the mdcast server's {status} response: {reason}")]
86 InvalidResponse { status: u16, reason: String },
87
88 #[error("asset key `{key}` is named by the manifest but the bundle holds no bytes for it")]
91 MissingAsset { key: String },
92
93 #[error("fetching asset `{key}` failed: {source}")]
97 BlobFetchFailed {
98 key: String,
99 #[source]
100 source: crate::bundle::BlobFetchError,
101 },
102
103 #[error("no asset in the bundle hashes to {digest}")]
106 UnknownDigest { digest: Digest },
107
108 #[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
121pub(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}