use std::time::Duration;
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)]
#[non_exhaustive]
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 is overloaded: {message}")]
Overloaded {
message: String,
retry_after: Option<Duration>,
},
#[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}")]
#[non_exhaustive]
Server {
status: u16,
code: ErrorCode,
message: String,
detail: Vec<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, retry_after: Option<Duration>, 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: ErrorCode::Overloaded,
message,
..
}) if status == 503 => Error::Overloaded {
message,
retry_after,
},
Some(ErrorResponse {
code,
message,
detail,
..
}) => Error::Server {
status,
code,
message,
detail,
},
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, None, &body("unauthorized", "missing or invalid bearer token")),
Error::Unauthorized { message } if message.contains("bearer token")
));
assert!(matches!(
from_status(413, None, &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, None, 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,
None,
&body("render_failed", "render failed (incident 7-0)"),
);
match err {
Error::Server {
status,
code,
message,
detail,
} => {
assert!(detail.is_empty());
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, None, &body("unsupported_target", "no typst-html here")),
Error::Server {
code: ErrorCode::UnsupportedTarget,
..
}
));
assert!(matches!(
from_status(500, None, &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, None, 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, None, &huge) {
Error::UnexpectedStatus { body, .. } => {
assert_eq!(body.chars().count(), SNIPPET_CHARS + 1, "{body}");
assert!(body.ends_with('…'));
}
other => panic!("expected UnexpectedStatus, got {other:?}"),
}
}
#[test]
fn an_overloaded_503_carries_its_retry_after() {
let err = from_status(
503,
Some(Duration::from_secs(7)),
&body("overloaded", "all render slots busy"),
);
assert!(matches!(
err,
Error::Overloaded { message, retry_after: Some(d) }
if message == "all render slots busy" && d == Duration::from_secs(7)
));
}
#[test]
fn a_proxys_503_is_not_overloaded() {
assert!(matches!(
from_status(503, None, b"<html>no healthy upstream</html>"),
Error::UnexpectedStatus { status: 503, .. }
));
}
#[test]
fn render_failed_detail_is_carried_through() {
let body = br#"{"code":"render_failed","message":"m","detail":["main.typ:1:1: error: x"]}"#;
assert!(matches!(
from_status(422, None, body),
Error::Server { detail, .. } if detail == ["main.typ:1:1: error: x"]
));
}
}