use mdcast_api::Digest;
use mdcast_api::wire::{ErrorCode, ErrorResponse, MissingBlob};
pub type Result<T> = std::result::Result<T, Error>;
const SNIPPET_CHARS: usize = 512;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid client configuration: {0}")]
Config(String),
#[error("could not reach the mdcast server: {0}")]
Transport(#[from] reqwest::Error),
#[error("the mdcast server rejected the bearer token: {message}")]
Unauthorized { message: String },
#[error("the mdcast server refused the request body as too large: {message}")]
PayloadTooLarge { message: String },
#[error(
"the mdcast server still reports {} blob(s) missing after uploading them and retrying once",
.missing.len()
)]
BlobsStillMissing { missing: Vec<MissingBlob> },
#[error("the mdcast server returned {status} ({code:?}): {message}")]
Server {
status: u16,
code: ErrorCode,
message: String,
},
#[error("unexpected {status} response from the mdcast server: {body}")]
UnexpectedStatus { status: u16, body: String },
#[error("could not decode the mdcast server's {status} response: {reason}")]
InvalidResponse { status: u16, reason: String },
#[error("asset key `{key}` is named by the manifest but the bundle holds no bytes for it")]
MissingAsset { key: String },
#[error("fetching asset `{key}` failed: {source}")]
BlobFetchFailed {
key: String,
#[source]
source: crate::bundle::BlobFetchError,
},
#[error("no asset in the bundle hashes to {digest}")]
UnknownDigest { digest: Digest },
#[error("asset key `{key}` is declared as {declared} but its bundle bytes hash to {actual}")]
DigestMismatch {
key: String,
declared: Digest,
actual: Digest,
},
}
pub(crate) fn from_status(status: u16, body: &[u8]) -> Error {
let parsed = serde_json::from_slice::<ErrorResponse>(body).ok();
match status {
401 => Error::Unauthorized {
message: message_of(parsed, body),
},
413 => Error::PayloadTooLarge {
message: message_of(parsed, body),
},
_ => match parsed {
Some(ErrorResponse { code, message }) => Error::Server {
status,
code,
message,
},
None => Error::UnexpectedStatus {
status,
body: snippet(body),
},
},
}
}
fn message_of(parsed: Option<ErrorResponse>, body: &[u8]) -> String {
parsed.map(|e| e.message).unwrap_or_else(|| snippet(body))
}
fn snippet(body: &[u8]) -> String {
let text = String::from_utf8_lossy(body);
let text = text.trim();
if text.chars().count() <= SNIPPET_CHARS {
return text.to_string();
}
let head: String = text.chars().take(SNIPPET_CHARS).collect();
format!("{head}…")
}
#[cfg(test)]
mod tests {
use super::*;
fn body(code: &str, message: &str) -> Vec<u8> {
format!(r#"{{"code":"{code}","message":"{message}"}}"#).into_bytes()
}
#[test]
fn unauthorized_and_too_large_get_their_own_variants() {
assert!(matches!(
from_status(401, &body("unauthorized", "missing or invalid bearer token")),
Error::Unauthorized { message } if message.contains("bearer token")
));
assert!(matches!(
from_status(413, &body("payload_too_large", "too big")),
Error::PayloadTooLarge { message } if message == "too big"
));
}
#[test]
fn an_intermediarys_bodyless_401_is_still_unauthorized() {
assert!(matches!(
from_status(401, b"<html>Forbidden by the proxy</html>"),
Error::Unauthorized { message } if message.contains("proxy")
));
}
#[test]
fn a_server_error_response_carries_its_code_through() {
let err = from_status(422, &body("render_failed", "render failed (incident 7-0)"));
match err {
Error::Server {
status,
code,
message,
} => {
assert_eq!(status, 422);
assert_eq!(code, ErrorCode::RenderFailed);
assert!(message.contains("incident 7-0"), "{message}");
}
other => panic!("expected Server, got {other:?}"),
}
assert!(matches!(
from_status(501, &body("unsupported_target", "no typst-html here")),
Error::Server {
code: ErrorCode::UnsupportedTarget,
..
}
));
assert!(matches!(
from_status(500, &body("internal", "internal error")),
Error::Server {
code: ErrorCode::Internal,
..
}
));
}
#[test]
fn a_body_that_is_not_an_error_response_is_unexpected() {
match from_status(502, b"<html>bad gateway</html>") {
Error::UnexpectedStatus { status, body } => {
assert_eq!(status, 502);
assert_eq!(body, "<html>bad gateway</html>");
}
other => panic!("expected UnexpectedStatus, got {other:?}"),
}
}
#[test]
fn an_oversized_body_is_truncated_on_a_char_boundary() {
let huge = "é".repeat(SNIPPET_CHARS * 2).into_bytes();
match from_status(502, &huge) {
Error::UnexpectedStatus { body, .. } => {
assert_eq!(body.chars().count(), SNIPPET_CHARS + 1, "{body}");
assert!(body.ends_with('…'));
}
other => panic!("expected UnexpectedStatus, got {other:?}"),
}
}
}