1use mdcast_api::Digest;
11use mdcast_api::wire::{ErrorCode, ErrorResponse, MissingBlob};
12
13pub type Result<T> = std::result::Result<T, Error>;
14
15const SNIPPET_CHARS: usize = 512;
18
19#[derive(Debug, thiserror::Error)]
20pub enum Error {
21 #[error("invalid client configuration: {0}")]
23 Config(String),
24
25 #[error("could not reach the mdcast server: {0}")]
28 Transport(#[from] reqwest::Error),
29
30 #[error("the mdcast server rejected the bearer token: {message}")]
33 Unauthorized { message: String },
34
35 #[error("the mdcast server refused the request body as too large: {message}")]
39 PayloadTooLarge { message: String },
40
41 #[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 #[error("the mdcast server returned {status} ({code:?}): {message}")]
54 Server {
55 status: u16,
56 code: ErrorCode,
57 message: String,
58 },
59
60 #[error("unexpected {status} response from the mdcast server: {body}")]
63 UnexpectedStatus { status: u16, body: String },
64
65 #[error("could not decode the mdcast server's {status} response: {reason}")]
67 InvalidResponse { status: u16, reason: String },
68
69 #[error("asset key `{key}` is named by the manifest but the bundle holds no bytes for it")]
72 MissingAsset { key: String },
73
74 #[error("fetching asset `{key}` failed: {source}")]
78 BlobFetchFailed {
79 key: String,
80 #[source]
81 source: crate::bundle::BlobFetchError,
82 },
83
84 #[error("no asset in the bundle hashes to {digest}")]
87 UnknownDigest { digest: Digest },
88
89 #[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
102pub(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}