use rapid_structure_rs::{DownloadError, RapidOcrError};
use serde::Serialize;
use super::state::OcrAdmission;
pub trait DownloadErrorMapping {
fn status_code(&self) -> u16;
fn code(&self) -> &'static str;
fn message(&self) -> String;
fn detail(&self) -> serde_json::Value;
}
impl DownloadErrorMapping for DownloadError {
fn status_code(&self) -> u16 {
match self {
Self::ConnectTimeout { .. } | Self::ReadTimeout { .. } => 504,
Self::InsufficientSpace { .. } => 507,
Self::TooLarge { .. } => 413,
Self::Cancelled => 409,
Self::SchemeRejected { .. }
| Self::RedirectRejected { .. }
| Self::HostRejected { .. }
| Self::Network { .. }
| Self::HashMismatch { .. } => 502,
}
}
fn code(&self) -> &'static str {
match self {
Self::ConnectTimeout { .. } | Self::ReadTimeout { .. } => "download_timeout",
Self::InsufficientSpace { .. } => "insufficient_disk_space",
Self::TooLarge { .. } => "payload_too_large",
Self::Cancelled => "download_cancelled",
Self::SchemeRejected { .. }
| Self::RedirectRejected { .. }
| Self::HostRejected { .. }
| Self::Network { .. }
| Self::HashMismatch { .. } => "download_failed",
}
}
fn message(&self) -> String {
match self {
Self::SchemeRejected { scheme } => {
format!("only https model downloads are allowed, got scheme `{scheme}`")
}
Self::RedirectRejected { location } => match location {
Some(location) => format!(
"the model host answered with a redirect to `{location}` whose chain is not \
trusted (only https, an allow-listed host, the same file name, and at most \
{} hops are followed; see docs/05 §6.1 item 2)",
rapid_structure_rs::MAX_REDIRECT_HOPS
),
None => "the model host answered with a redirect that carries no usable \
`Location` header"
.to_string(),
},
Self::HostRejected { host } => format!(
"`{host}` is not in the trusted download host allow-list; pass \
--allow-download-host to extend it explicitly"
),
Self::TooLarge {
limit_bytes,
observed_bytes,
} => match observed_bytes {
Some(observed) => format!(
"the download is {observed} bytes, which exceeds the {limit_bytes} byte limit"
),
None => format!("the download exceeds the {limit_bytes} byte limit"),
},
Self::Network { detail } => format!("the download failed: {detail}"),
Self::ConnectTimeout { timeout_ms } => {
format!("the download could not connect within {timeout_ms} ms")
}
Self::ReadTimeout { timeout_ms } => {
format!("the download stalled for more than {timeout_ms} ms while reading")
}
Self::InsufficientSpace {
required_bytes,
available_bytes,
} => format!(
"insufficient disk space: {required_bytes} bytes required, {available_bytes} \
bytes available"
),
Self::HashMismatch { expected, actual } => format!(
"the downloaded file does not match the expected SHA-256 (expected {expected}, got \
{actual})"
),
Self::Cancelled => "the download was cancelled at a file boundary".to_string(),
}
}
fn detail(&self) -> serde_json::Value {
let kind = self.kind();
match self {
Self::SchemeRejected { scheme } => {
serde_json::json!({ "kind": kind, "scheme": scheme })
}
Self::RedirectRejected { location } => {
serde_json::json!({ "kind": kind, "location": location })
}
Self::HostRejected { host } => serde_json::json!({ "kind": kind, "host": host }),
Self::TooLarge {
limit_bytes,
observed_bytes,
} => serde_json::json!({
"kind": kind,
"limit_bytes": limit_bytes,
"observed_bytes": observed_bytes,
}),
Self::Network { detail } => serde_json::json!({ "kind": kind, "error": detail }),
Self::ConnectTimeout { timeout_ms } => {
serde_json::json!({ "kind": kind, "timeout_ms": timeout_ms })
}
Self::ReadTimeout { timeout_ms } => {
serde_json::json!({ "kind": kind, "timeout_ms": timeout_ms })
}
Self::InsufficientSpace {
required_bytes,
available_bytes,
} => serde_json::json!({
"kind": kind,
"required_bytes": required_bytes,
"available_bytes": available_bytes,
}),
Self::HashMismatch { expected, actual } => serde_json::json!({
"kind": kind,
"expected": expected,
"actual": actual,
}),
Self::Cancelled => serde_json::json!({ "kind": kind }),
}
}
}
#[derive(Debug)]
pub enum ServeError {
BadRequest,
PayloadTooLarge,
ResultTooLarge,
ExportTooLarge {
limit_bytes: u64,
observed_bytes: u64,
annotated: String,
},
BadHost,
BadOrigin,
Unauthorized,
RequestTimeout,
Busy,
JobNotFound,
JobEvicted,
OriginalEvicted,
JobNotFinished,
NotCancellable,
ModelsMissing,
ModelsCorrupt,
DownloadsDisabled,
ProviderRejected {
provider: String,
reason: String,
},
InsufficientDiskSpace {
required_bytes: u64,
available_bytes: u64,
},
ModelSetNotFound {
set_id: String,
known: Vec<String>,
},
Evaluation {
reason: String,
},
#[allow(dead_code)]
UnsupportedInput,
EngineUnavailable {
reason: String,
},
Download(DownloadError),
Ocr(RapidOcrError),
Internal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct OcrErrorClass {
status: u16,
code: &'static str,
kind: &'static str,
reason_is_message: bool,
}
fn classify_ocr_error(error: &RapidOcrError) -> OcrErrorClass {
match error {
RapidOcrError::Config(_) => OcrErrorClass {
status: 500,
code: "internal",
kind: "config",
reason_is_message: false,
},
RapidOcrError::ModelResolve(_) => OcrErrorClass {
status: 409,
code: "models_missing",
kind: "model_resolve",
reason_is_message: false,
},
RapidOcrError::FileNotFound(_) => OcrErrorClass {
status: 409,
code: "models_missing",
kind: "file_not_found",
reason_is_message: false,
},
RapidOcrError::Download(error) => OcrErrorClass {
status: error.status_code(),
code: error.code(),
kind: error.kind(),
reason_is_message: false,
},
RapidOcrError::InvalidImage(_) => OcrErrorClass {
status: 422,
code: "unsupported_input",
kind: "invalid_image",
reason_is_message: false,
},
RapidOcrError::InvalidInput(_) => OcrErrorClass {
status: 422,
code: "unsupported_input",
kind: "invalid_input",
reason_is_message: false,
},
RapidOcrError::Decode(_) => OcrErrorClass {
status: 422,
code: "unsupported_input",
kind: "decode",
reason_is_message: false,
},
RapidOcrError::Tokenizer(_) => OcrErrorClass {
status: 409,
code: "models_corrupt",
kind: "tokenizer",
reason_is_message: false,
},
RapidOcrError::UnsupportedProvider(_) => OcrErrorClass {
status: 503,
code: "engine_unavailable",
kind: "unsupported_provider",
reason_is_message: true,
},
RapidOcrError::UnsupportedBackend(_) => OcrErrorClass {
status: 503,
code: "engine_unavailable",
kind: "unsupported_backend",
reason_is_message: true,
},
RapidOcrError::Io(_) => OcrErrorClass {
status: 500,
code: "internal",
kind: "io",
reason_is_message: false,
},
RapidOcrError::Yaml(_) => OcrErrorClass {
status: 500,
code: "internal",
kind: "yaml",
reason_is_message: false,
},
RapidOcrError::Reqwest(_) => OcrErrorClass {
status: 502,
code: "download_failed",
kind: "reqwest",
reason_is_message: false,
},
RapidOcrError::HashMismatch { .. } => OcrErrorClass {
status: 409,
code: "models_corrupt",
kind: "hash_mismatch",
reason_is_message: false,
},
}
}
impl ServeError {
pub fn status_code(&self) -> u16 {
match self {
Self::BadRequest => 400,
Self::Unauthorized => 401,
Self::BadOrigin | Self::DownloadsDisabled => 403,
Self::RequestTimeout => 408,
Self::JobNotFound => 404,
Self::ModelSetNotFound { .. } => 404,
Self::JobNotFinished
| Self::NotCancellable
| Self::ModelsMissing
| Self::ModelsCorrupt => 409,
Self::JobEvicted | Self::OriginalEvicted => 410,
Self::PayloadTooLarge | Self::ResultTooLarge | Self::ExportTooLarge { .. } => 413,
Self::BadHost => 421,
Self::UnsupportedInput => 422,
Self::Busy | Self::EngineUnavailable { .. } => 503,
Self::InsufficientDiskSpace { .. } => 507,
Self::ProviderRejected { .. } | Self::Evaluation { .. } => 400,
Self::Download(error) => error.status_code(),
Self::Ocr(error) => classify_ocr_error(error).status,
Self::Internal => 500,
}
}
pub fn code(&self) -> &'static str {
match self {
Self::BadRequest => "bad_request",
Self::Unauthorized => "unauthorized",
Self::BadOrigin => "bad_origin",
Self::DownloadsDisabled => "downloads_disabled",
Self::RequestTimeout => "request_timeout",
Self::JobNotFound => "job_not_found",
Self::ModelSetNotFound { .. } => "model_set_not_found",
Self::JobNotFinished => "job_not_finished",
Self::JobEvicted => "job_evicted",
Self::OriginalEvicted => "original_evicted",
Self::NotCancellable => "not_cancellable",
Self::ModelsMissing => "models_missing",
Self::ModelsCorrupt => "models_corrupt",
Self::PayloadTooLarge => "payload_too_large",
Self::ResultTooLarge => "result_too_large",
Self::ExportTooLarge { .. } => "export_too_large",
Self::BadHost => "bad_host",
Self::UnsupportedInput => "unsupported_input",
Self::Busy => "busy",
Self::EngineUnavailable { .. } => "engine_unavailable",
Self::InsufficientDiskSpace { .. } => "insufficient_disk_space",
Self::ProviderRejected { .. } | Self::Evaluation { .. } => "bad_request",
Self::Download(error) => error.code(),
Self::Ocr(error) => classify_ocr_error(error).code,
Self::Internal => "internal",
}
}
pub fn message(&self) -> String {
match self {
Self::BadRequest => "the request is malformed".to_string(),
Self::Unauthorized => "missing or invalid X-RapidStructure-Token".to_string(),
Self::BadOrigin => {
"state-changing requests must carry an Origin equal to this server's origin"
.to_string()
}
Self::DownloadsDisabled => {
"model downloads are disabled; restart with --allow-download to enable them"
.to_string()
}
Self::RequestTimeout => {
"the request body did not arrive within the read timeout".to_string()
}
Self::JobNotFound => "no such job".to_string(),
Self::JobNotFinished => "the job has not finished yet".to_string(),
Self::JobEvicted => "the job result has been evicted".to_string(),
Self::OriginalEvicted => {
"the retained copy of the original image has been released; the annotated image \
can no longer be regenerated for this job (the result itself is still \
available), upload the image again to annotate it"
.to_string()
}
Self::NotCancellable => {
"the job cannot be cancelled; only a job still queued can be cancelled".to_string()
}
Self::ModelsMissing => "the model set is incomplete".to_string(),
Self::ModelsCorrupt => "a model file is present but its content is wrong".to_string(),
Self::PayloadTooLarge => "the request body exceeds the configured limit".to_string(),
Self::ResultTooLarge => {
"the serialized result exceeds the configured limit".to_string()
}
Self::ExportTooLarge {
limit_bytes,
observed_bytes,
annotated,
} => format!(
"the exported document would be {observed_bytes} bytes, over the \
--max-export-mb limit of {limit_bytes} bytes; download the annotated PNG from \
{annotated} instead (it is not subject to the export budget)"
),
Self::ProviderRejected { provider, reason } => {
format!("the requested provider `{provider}` cannot be used: {reason}")
}
Self::Evaluation { reason } => {
format!("the evaluation request was rejected: {reason}")
}
Self::BadHost => {
"the Host header does not belong to this server (possible DNS rebinding)"
.to_string()
}
Self::UnsupportedInput => {
"the input image cannot be decoded or exceeds the limits".to_string()
}
Self::Busy => "the request queue is full; retry later".to_string(),
Self::EngineUnavailable { reason } => {
format!("the OCR engine is not available: {reason}")
}
Self::InsufficientDiskSpace {
required_bytes,
available_bytes,
} => format!(
"insufficient disk space for the download: {required_bytes} bytes required, \
{available_bytes} bytes available"
),
Self::ModelSetNotFound { set_id, known } => format!(
"no model set with id `{set_id}` exists in this model directory (known sets: {})",
if known.is_empty() {
"<none>".to_string()
} else {
known.join(", ")
}
),
Self::Download(error) => error.message(),
Self::Ocr(error) => error.to_string(),
Self::Internal => "internal error".to_string(),
}
}
pub fn detail_text(&self) -> String {
let detail = self.detail();
if detail.is_null() {
return String::new();
}
serde_json::to_string(&detail).unwrap_or_else(|_| detail.to_string())
}
pub fn detail(&self) -> serde_json::Value {
match self {
Self::EngineUnavailable { reason } => serde_json::json!({ "reason": reason }),
Self::InsufficientDiskSpace {
required_bytes,
available_bytes,
} => serde_json::json!({
"required_bytes": required_bytes,
"available_bytes": available_bytes,
}),
Self::ModelSetNotFound { set_id, known } => serde_json::json!({
"set_id": set_id,
"known_sets": known,
}),
Self::ExportTooLarge {
limit_bytes,
observed_bytes,
annotated,
} => serde_json::json!({
"limit_bytes": limit_bytes,
"observed_bytes": observed_bytes,
"annotated": annotated,
}),
Self::ProviderRejected { provider, reason } => serde_json::json!({
"provider": provider,
"reason": reason,
}),
Self::Evaluation { reason } => serde_json::json!({ "reason": reason }),
Self::Download(error) => error.detail(),
Self::Ocr(error) => {
let class = classify_ocr_error(error);
let text = error.to_string();
let mut detail = serde_json::Map::new();
detail.insert(
"kind".to_string(),
serde_json::Value::String(class.kind.to_string()),
);
if class.reason_is_message {
detail.insert(
"reason".to_string(),
serde_json::Value::String(text.clone()),
);
}
detail.insert("error".to_string(), serde_json::Value::String(text));
serde_json::Value::Object(detail)
}
_ => serde_json::Value::Null,
}
}
pub fn body(&self) -> ErrorBody {
ErrorBody {
code: self.code(),
message: self.message(),
detail: self.detail(),
}
}
pub fn render_body(&self) -> String {
serde_json::to_string(&self.body()).expect("ErrorBody serialization cannot fail")
}
pub fn from_ocr_admission(admission: OcrAdmission) -> Result<(), Self> {
match admission {
OcrAdmission::Run | OcrAdmission::Queue { .. } => Ok(()),
OcrAdmission::ModelsMissing { .. } => Err(Self::ModelsMissing),
OcrAdmission::Unavailable { reason } => Err(Self::EngineUnavailable { reason }),
}
}
}
impl std::fmt::Display for ServeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code(), self.message())
}
}
impl std::error::Error for ServeError {}
impl From<RapidOcrError> for ServeError {
fn from(error: RapidOcrError) -> Self {
Self::Ocr(error)
}
}
impl From<DownloadError> for ServeError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorBody {
pub code: &'static str,
pub message: String,
pub detail: serde_json::Value,
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use rapid_structure_rs::RapidOcrError;
use super::{DownloadError, DownloadErrorMapping, ServeError};
#[test]
fn every_variant_has_the_documented_status_and_code() {
let cases: [(ServeError, u16, &str); 22] = [
(ServeError::BadRequest, 400, "bad_request"),
(ServeError::Unauthorized, 401, "unauthorized"),
(ServeError::BadOrigin, 403, "bad_origin"),
(ServeError::DownloadsDisabled, 403, "downloads_disabled"),
(ServeError::RequestTimeout, 408, "request_timeout"),
(ServeError::JobNotFound, 404, "job_not_found"),
(
ServeError::ModelSetNotFound {
set_id: "nope".to_string(),
known: vec!["test-set".to_string()],
},
404,
"model_set_not_found",
),
(ServeError::JobNotFinished, 409, "job_not_finished"),
(ServeError::JobEvicted, 410, "job_evicted"),
(ServeError::OriginalEvicted, 410, "original_evicted"),
(ServeError::NotCancellable, 409, "not_cancellable"),
(ServeError::ModelsMissing, 409, "models_missing"),
(ServeError::ModelsCorrupt, 409, "models_corrupt"),
(ServeError::PayloadTooLarge, 413, "payload_too_large"),
(ServeError::ResultTooLarge, 413, "result_too_large"),
(
ServeError::ExportTooLarge {
limit_bytes: 1024,
observed_bytes: 4096,
annotated: "/api/jobs/job-0/annotated.png".to_string(),
},
413,
"export_too_large",
),
(
ServeError::ProviderRejected {
provider: "directml".to_string(),
reason: "not compiled in".to_string(),
},
400,
"bad_request",
),
(ServeError::BadHost, 421, "bad_host"),
(ServeError::UnsupportedInput, 422, "unsupported_input"),
(ServeError::Busy, 503, "busy"),
(
ServeError::EngineUnavailable {
reason: "no EP".to_string(),
},
503,
"engine_unavailable",
),
(
ServeError::InsufficientDiskSpace {
required_bytes: 1000,
available_bytes: 10,
},
507,
"insufficient_disk_space",
),
];
for (error, status, code) in cases {
assert_eq!(error.status_code(), status, "error: {error}");
assert_eq!(error.code(), code, "error: {error}");
assert!(!error.message().is_empty(), "error: {error}");
}
assert_eq!(ServeError::Internal.status_code(), 500);
assert_eq!(ServeError::Internal.code(), "internal");
}
#[test]
fn every_download_error_maps_to_a_documented_status_and_code() {
let cases: [(DownloadError, u16, &str, &str); 10] = [
(
DownloadError::SchemeRejected {
scheme: "http".to_string(),
},
502,
"download_failed",
"scheme",
),
(
DownloadError::RedirectRejected {
location: Some("https://evil.example/m.onnx".to_string()),
},
502,
"download_failed",
"redirect",
),
(
DownloadError::HostRejected {
host: "evil.example".to_string(),
},
502,
"download_failed",
"host",
),
(
DownloadError::TooLarge {
limit_bytes: 1024,
observed_bytes: Some(2048),
},
413,
"payload_too_large",
"too_large",
),
(
DownloadError::Network {
detail: "connection reset".to_string(),
},
502,
"download_failed",
"network",
),
(
DownloadError::ConnectTimeout { timeout_ms: 10_000 },
504,
"download_timeout",
"connect_timeout",
),
(
DownloadError::ReadTimeout { timeout_ms: 30_000 },
504,
"download_timeout",
"read_timeout",
),
(
DownloadError::InsufficientSpace {
required_bytes: 100,
available_bytes: 1,
},
507,
"insufficient_disk_space",
"insufficient_space",
),
(
DownloadError::HashMismatch {
expected: "aa".to_string(),
actual: "bb".to_string(),
},
502,
"download_failed",
"hash_mismatch",
),
(
DownloadError::Cancelled,
409,
"download_cancelled",
"cancelled",
),
];
for (download, status, code, kind) in cases {
assert_eq!(download.status_code(), status, "download: {download}");
assert_eq!(download.code(), code, "download: {download}");
assert_eq!(download.kind(), kind, "download: {download}");
let error = ServeError::Download(download);
assert_eq!(error.status_code(), status, "error: {error}");
assert_eq!(error.code(), code, "error: {error}");
assert_eq!(error.detail()["kind"], kind);
assert!(!error.message().is_empty(), "error: {error}");
}
}
#[test]
fn download_details_carry_the_numbers() {
let error = ServeError::Download(DownloadError::TooLarge {
limit_bytes: 1024,
observed_bytes: Some(4096),
});
let detail = error.detail();
assert_eq!(detail["limit_bytes"], 1024);
assert_eq!(detail["observed_bytes"], 4096);
let error = ServeError::Download(DownloadError::InsufficientSpace {
required_bytes: 600_000_000,
available_bytes: 12_345,
});
let detail = error.detail();
assert_eq!(detail["required_bytes"], 600_000_000u64);
assert_eq!(detail["available_bytes"], 12_345);
let error = ServeError::Download(DownloadError::HashMismatch {
expected: "aa".to_string(),
actual: "bb".to_string(),
});
assert_eq!(error.detail()["expected"], "aa");
assert_eq!(error.detail()["actual"], "bb");
assert!(error.message().contains("SHA-256"), "{}", error.message());
let error = ServeError::InsufficientDiskSpace {
required_bytes: 600_000_000,
available_bytes: 12_345,
};
let detail = error.detail();
assert_eq!(error.status_code(), 507);
assert_eq!(detail["required_bytes"], 600_000_000u64);
assert_eq!(detail["available_bytes"], 12_345);
assert!(error.message().contains("600000000"), "{}", error.message());
assert!(error.message().contains("12345"), "{}", error.message());
let error = ServeError::ModelSetNotFound {
set_id: "nope".to_string(),
known: vec!["PP-OCRv6".to_string()],
};
assert_eq!(error.status_code(), 404);
assert_eq!(error.detail()["set_id"], "nope");
assert_eq!(error.detail()["known_sets"][0], "PP-OCRv6");
assert!(error.message().contains("nope"), "{}", error.message());
assert!(error.message().contains("PP-OCRv6"), "{}", error.message());
let error = ServeError::ExportTooLarge {
limit_bytes: 33_554_432,
observed_bytes: 40_000_000,
annotated: "/api/jobs/job-0/annotated.png".to_string(),
};
let detail = error.detail();
assert_eq!(error.status_code(), 413);
assert_eq!(error.code(), "export_too_large");
assert_eq!(detail["limit_bytes"], 33_554_432u64);
assert_eq!(detail["observed_bytes"], 40_000_000u64);
assert_eq!(detail["annotated"], "/api/jobs/job-0/annotated.png");
assert!(
error.message().contains("annotated.png"),
"{}",
error.message()
);
assert!(error.message().contains("33554432"), "{}", error.message());
assert!(error.message().contains("40000000"), "{}", error.message());
let error = ServeError::ProviderRejected {
provider: "directml".to_string(),
reason: "execution provider directml is not compiled in; rebuild with \
`--features directml-provider`"
.to_string(),
};
assert_eq!(error.status_code(), 400);
assert_eq!(error.code(), "bad_request");
assert_eq!(error.detail()["provider"], "directml");
assert!(
error.detail()["reason"]
.as_str()
.is_some_and(|reason| reason.contains("--features directml-provider")),
"{:?}",
error.detail()
);
}
#[test]
fn every_rapid_structure_error_variant_is_mapped() {
let http = reqwest::blocking::Client::new()
.get("http://127.0.0.1:99999/")
.send()
.expect_err("an out-of-range port must fail before any connection");
let cases: [(RapidOcrError, u16, &str, &str); 15] = [
(
RapidOcrError::Config("bad".to_string()),
500,
"internal",
"config",
),
(
RapidOcrError::ModelResolve("missing".to_string()),
409,
"models_missing",
"model_resolve",
),
(
RapidOcrError::FileNotFound(PathBuf::from("det.onnx")),
409,
"models_missing",
"file_not_found",
),
(
RapidOcrError::Download(DownloadError::Network {
detail: "boom".to_string(),
}),
502,
"download_failed",
"network",
),
(
RapidOcrError::Download(DownloadError::ReadTimeout { timeout_ms: 30_000 }),
504,
"download_timeout",
"read_timeout",
),
(
RapidOcrError::InvalidImage("truncated".to_string()),
422,
"unsupported_input",
"invalid_image",
),
(
RapidOcrError::InvalidInput("huge".to_string()),
422,
"unsupported_input",
"invalid_input",
),
(
RapidOcrError::Decode("bad".to_string()),
422,
"unsupported_input",
"decode",
),
(
RapidOcrError::Tokenizer("bad".to_string()),
409,
"models_corrupt",
"tokenizer",
),
(
RapidOcrError::UnsupportedProvider("not compiled in".to_string()),
503,
"engine_unavailable",
"unsupported_provider",
),
(
RapidOcrError::UnsupportedBackend("no backend".to_string()),
503,
"engine_unavailable",
"unsupported_backend",
),
(
RapidOcrError::Io(std::io::Error::other("io")),
500,
"internal",
"io",
),
(
RapidOcrError::Yaml(serde_yaml::from_str::<u32>("nope").unwrap_err()),
500,
"internal",
"yaml",
),
(
RapidOcrError::Reqwest(http),
502,
"download_failed",
"reqwest",
),
(
RapidOcrError::HashMismatch {
path: PathBuf::from("det.onnx"),
expected: "aa".to_string(),
actual: "bb".to_string(),
},
409,
"models_corrupt",
"hash_mismatch",
),
];
for (library_error, status, code, kind) in cases {
let error = ServeError::from(library_error);
assert_eq!(error.status_code(), status, "error: {error}");
assert_eq!(error.code(), code, "error: {error}");
assert_eq!(error.detail()["kind"], kind, "error: {error}");
}
}
#[test]
fn engine_unavailable_always_carries_a_reason() {
let error = ServeError::EngineUnavailable {
reason: "DirectML is unavailable".to_string(),
};
assert_eq!(error.detail()["reason"], "DirectML is unavailable");
assert!(error.message().contains("DirectML is unavailable"));
let error = ServeError::from(RapidOcrError::UnsupportedProvider(
"CUDA provider support is not compiled in".to_string(),
));
assert_eq!(error.code(), "engine_unavailable");
assert_eq!(
error.detail()["reason"],
"unsupported provider for v1: CUDA provider support is not compiled in"
);
}
#[test]
fn error_body_always_has_code_message_and_detail_keys() {
let bodies = [
ServeError::BadRequest,
ServeError::JobEvicted,
ServeError::Internal,
ServeError::EngineUnavailable {
reason: "r".to_string(),
},
ServeError::Download(DownloadError::Cancelled),
ServeError::from(RapidOcrError::InvalidImage("truncated".to_string())),
];
for error in bodies {
let text = error.render_body();
let value: serde_json::Value =
serde_json::from_str(&text).expect("the error body must be valid JSON");
let object = value.as_object().expect("the body must be an object");
assert_eq!(
object.len(),
3,
"the body must have exactly code/message/detail: {text}"
);
assert_eq!(value["code"], error.code(), "body: {text}");
assert_eq!(value["message"], error.message(), "body: {text}");
assert!(object.contains_key("detail"), "body: {text}");
let code_at = text.find("\"code\"").expect("code key");
let message_at = text.find("\"message\"").expect("message key");
let detail_at = text.find("\"detail\"").expect("detail key");
assert!(
code_at < message_at && message_at < detail_at,
"body: {text}"
);
}
let body = ServeError::JobNotFound.body();
assert_eq!(body.code, "job_not_found");
assert_eq!(body.message, "no such job");
assert!(body.detail.is_null(), "unit variants carry `null` detail");
}
#[test]
fn display_includes_the_code_and_the_message() {
let error = ServeError::Busy;
let text = error.to_string();
assert!(text.starts_with("busy: "), "{text}");
assert!(text.contains("retry later"), "{text}");
}
}