use boxlite_shared::errors::BoxliteError;
use reqwest::StatusCode;
use super::types::ErrorModel;
pub(crate) fn map_http_error(status: StatusCode, body: &ErrorModel) -> BoxliteError {
let msg = body.message.clone();
match body.code.as_str() {
"invalid_argument" => BoxliteError::InvalidArgument(msg),
"unsupported" => BoxliteError::Unsupported(msg),
"unauthenticated" | "permission_denied" => BoxliteError::Config(format!("auth: {}", msg)),
"not_found" => BoxliteError::NotFound(msg),
"session_reaped" => BoxliteError::SessionReaped(msg),
"already_exists" => BoxliteError::AlreadyExists(msg),
"invalid_state" => BoxliteError::InvalidState(msg),
"stopped" => BoxliteError::Stopped(msg),
"image_pull_failed" => BoxliteError::Image(msg),
"execution_failed" => BoxliteError::Execution(msg),
"resource_exhausted" => BoxliteError::ResourceExhausted(msg),
"network_unavailable" => BoxliteError::Network(msg),
"upstream_unavailable" => BoxliteError::Portal(msg),
"engine_unavailable" => BoxliteError::Engine(msg),
"storage_error" => BoxliteError::Storage(msg),
"database_error" => BoxliteError::Database(msg),
"metadata_error" => BoxliteError::MetadataError(msg),
"config_error" => BoxliteError::Config(msg),
"timeout" => BoxliteError::Internal(format!("server timed out: {}", msg)),
"internal" => BoxliteError::Internal(msg),
_ => map_http_status(status, &msg),
}
}
pub(crate) fn map_http_status(status: StatusCode, text: &str) -> BoxliteError {
match status.as_u16() {
404 => BoxliteError::NotFound(text.to_string()),
401 => BoxliteError::Config(format!("auth: unauthorized (HTTP 401): {}", text)),
403 => BoxliteError::Config(format!("auth: forbidden (HTTP 403): {}", text)),
502..=504 => BoxliteError::Network(format!(
"upstream returned HTTP {} (no error envelope; likely a \
proxy or load balancer in front of the server). Body: {}",
status,
if text.is_empty() { "<empty>" } else { text }
)),
_ => BoxliteError::Internal(format!("HTTP {}: {}", status, text)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn body(msg: &str, etype: &str, code: &str) -> ErrorModel {
ErrorModel {
message: msg.to_string(),
error_type: etype.to_string(),
code: code.to_string(),
request_id: None,
}
}
type RoundTripRow = (u16, &'static str, &'static str, fn(&BoxliteError) -> bool);
#[test]
fn round_trip_canonical_table() {
let cases: &[RoundTripRow] = &[
(400, "InvalidArgumentError", "invalid_argument", |e| {
matches!(e, BoxliteError::InvalidArgument(_))
}),
(400, "UnsupportedError", "unsupported", |e| {
matches!(e, BoxliteError::Unsupported(_))
}),
(401, "AuthError", "unauthenticated", |e| {
matches!(e, BoxliteError::Config(_))
}),
(403, "AuthError", "permission_denied", |e| {
matches!(e, BoxliteError::Config(_))
}),
(404, "NotFoundError", "not_found", |e| {
matches!(e, BoxliteError::NotFound(_))
}),
(410, "SessionReapedError", "session_reaped", |e| {
matches!(e, BoxliteError::SessionReaped(_))
}),
(409, "AlreadyExistsError", "already_exists", |e| {
matches!(e, BoxliteError::AlreadyExists(_))
}),
(409, "InvalidStateError", "invalid_state", |e| {
matches!(e, BoxliteError::InvalidState(_))
}),
(409, "StoppedError", "stopped", |e| {
matches!(e, BoxliteError::Stopped(_))
}),
(422, "ImageError", "image_pull_failed", |e| {
matches!(e, BoxliteError::Image(_))
}),
(422, "ExecutionError", "execution_failed", |e| {
matches!(e, BoxliteError::Execution(_))
}),
(429, "ResourceExhaustedError", "resource_exhausted", |e| {
matches!(e, BoxliteError::ResourceExhausted(_))
}),
(503, "NetworkError", "network_unavailable", |e| {
matches!(e, BoxliteError::Network(_))
}),
(
503,
"UpstreamUnavailableError",
"upstream_unavailable",
|e| matches!(e, BoxliteError::Portal(_)),
),
(503, "EngineError", "engine_unavailable", |e| {
matches!(e, BoxliteError::Engine(_))
}),
(500, "StorageError", "storage_error", |e| {
matches!(e, BoxliteError::Storage(_))
}),
(500, "DatabaseError", "database_error", |e| {
matches!(e, BoxliteError::Database(_))
}),
(500, "MetadataError", "metadata_error", |e| {
matches!(e, BoxliteError::MetadataError(_))
}),
(500, "ConfigError", "config_error", |e| {
matches!(e, BoxliteError::Config(_))
}),
(500, "InternalError", "internal", |e| {
matches!(e, BoxliteError::Internal(_))
}),
(504, "TimeoutError", "timeout", |e| {
matches!(e, BoxliteError::Internal(_))
}),
];
for (status_u16, etype, code, predicate) in cases {
let status = StatusCode::from_u16(*status_u16).expect("valid HTTP status");
let err = map_http_error(status, &body("msg", etype, code));
assert!(
predicate(&err),
"code {:?} (HTTP {}) mapped to unexpected variant: {:?}",
code,
status_u16,
err
);
}
}
#[test]
fn unknown_code_falls_back_to_status_mapping() {
let err = map_http_error(
StatusCode::IM_A_TEAPOT,
&body("can't brew", "TeapotError", "teapot_brewing_failed"),
);
match err {
BoxliteError::Internal(s) => {
assert!(s.contains("418"), "fallback should mention status: {s}");
assert!(
s.contains("can't brew"),
"fallback should mention body: {s}"
);
}
other => panic!("expected Internal fallback, got {other:?}"),
}
}
#[test]
fn bare_5xx_without_envelope_is_network_error() {
for status_u16 in [502, 503, 504] {
let status = StatusCode::from_u16(status_u16).unwrap();
let err = map_http_status(status, "");
assert!(
matches!(err, BoxliteError::Network(_)),
"HTTP {} with empty body should map to Network, got {:?}",
status_u16,
err
);
}
}
#[test]
fn bare_500_without_envelope_is_internal() {
let err = map_http_status(StatusCode::INTERNAL_SERVER_ERROR, "");
assert!(matches!(err, BoxliteError::Internal(_)));
}
#[test]
fn bare_auth_status_routes_to_config() {
let err = map_http_status(StatusCode::UNAUTHORIZED, "no token");
assert!(matches!(err, BoxliteError::Config(_)));
let err = map_http_status(StatusCode::FORBIDDEN, "wrong scope");
assert!(matches!(err, BoxliteError::Config(_)));
}
#[test]
fn bare_404_is_not_found() {
let err = map_http_status(StatusCode::NOT_FOUND, "");
assert!(matches!(err, BoxliteError::NotFound(_)));
}
}