use boxlite_shared::errors::BoxliteError;
use reqwest::StatusCode;
use super::types::{ErrorModel, ErrorResponse, FlatErrorResponse};
pub(crate) fn map_http_body(status: StatusCode, text: &str) -> BoxliteError {
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(text) {
map_enveloped(status, &err_resp.error)
} else if let Ok(err_resp) = serde_json::from_str::<FlatErrorResponse>(text) {
map_enveloped(status, &err_resp.into_error_model())
} else {
match envelope_message(text) {
Some(sentence) => map_status(status, &sentence),
None => map_status(status, text),
}
}
}
fn map_enveloped(status: StatusCode, body: &ErrorModel) -> BoxliteError {
let msg = body.message.clone();
if let Some(err) = refine_by_code(&body.code, &msg) {
return err;
}
if let Some(err) = refine_by_status(status, &msg) {
return err;
}
if body.code.is_empty() {
return BoxliteError::Internal(msg);
}
BoxliteError::Internal(msg)
}
fn refine_by_code(code: &str, msg: &str) -> Option<BoxliteError> {
let msg = msg.to_string();
Some(match code {
"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" | "runner_non_json_error" => 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),
_ => return None,
})
}
fn refine_by_status(status: StatusCode, text: &str) -> Option<BoxliteError> {
let msg = text.to_string();
Some(match status.as_u16() {
400 => BoxliteError::InvalidArgument(msg),
401 => BoxliteError::Config(format!("auth: unauthorized (HTTP 401): {}", text)),
403 => BoxliteError::Config(format!("auth: forbidden (HTTP 403): {}", text)),
404 => BoxliteError::NotFound(msg),
408 => BoxliteError::Internal(format!("server timed out: {}", text)),
409 => BoxliteError::InvalidState(msg),
428 => BoxliteError::InvalidState(msg),
429 => BoxliteError::ResourceExhausted(msg),
_ => return None,
})
}
pub(crate) fn envelope_message(text: &str) -> Option<String> {
#[derive(serde::Deserialize)]
struct BareError {
error: String,
}
if let Ok(resp) = serde_json::from_str::<ErrorResponse>(text) {
return Some(resp.error.message);
}
if let Ok(resp) = serde_json::from_str::<FlatErrorResponse>(text) {
return Some(resp.message);
}
serde_json::from_str::<BareError>(text)
.ok()
.map(|resp| resp.error)
}
pub(crate) fn map_plain_reply(status: StatusCode, text: &str) -> BoxliteError {
refine_by_status(status, text).unwrap_or_else(|| {
if status.is_server_error() {
BoxliteError::Network(text.to_string())
} else {
BoxliteError::Internal(format!("HTTP {}: {}", status, text))
}
})
}
fn map_status(status: StatusCode, text: &str) -> BoxliteError {
refine_by_status(status, text).unwrap_or_else(|| match status.as_u16() {
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);
type BaselineRow = (u16, fn(&BoxliteError) -> bool, &'static str);
#[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_enveloped(status, &body("msg", etype, code));
assert!(
predicate(&err),
"code {:?} (HTTP {}) mapped to unexpected variant: {:?}",
code,
status_u16,
err
);
let refined = refine_by_code(code, "msg")
.unwrap_or_else(|| panic!("code {:?} has no arm of its own", code));
assert!(
predicate(&refined),
"code {:?} refined to unexpected variant: {:?}",
code,
refined
);
}
}
#[test]
fn unknown_code_keeps_the_status_baseline() {
let err = map_http_body(
StatusCode::BAD_REQUEST,
r#"{"statusCode":400,"error":"Bad Request","message":"cpus is negative","code":"future_error"}"#,
);
assert!(
matches!(err, BoxliteError::InvalidArgument(_)),
"unknown code on a 400 stays the caller's error: {err:?}"
);
assert!(err.to_string().contains("cpus is negative"), "{err:?}");
let err = map_http_body(
StatusCode::TOO_MANY_REQUESTS,
r#"{"statusCode":429,"error":"Too Many Requests","message":"slow down","code":"quota_exceeded"}"#,
);
assert!(
matches!(err, BoxliteError::ResourceExhausted(_)),
"unknown code on a 429 stays resource exhaustion: {err:?}"
);
}
#[test]
fn unknown_code_stays_a_server_fault() {
let err = map_enveloped(
StatusCode::IM_A_TEAPOT,
&body("can't brew", "TeapotError", "teapot_brewing_failed"),
);
match err {
BoxliteError::Internal(s) => {
assert!(
s.contains("can't brew"),
"the server's sentence must survive: {s}"
);
}
other => panic!("expected Internal fallback, got {other:?}"),
}
let err = map_enveloped(
StatusCode::SERVICE_UNAVAILABLE,
&body(
"draining us-east-1",
"RegionDrainingError",
"region_draining",
),
);
assert!(
matches!(err, BoxliteError::Internal(_)),
"an unknown 503 code stays the server's own fault: {err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("draining us-east-1"),
"the server's sentence must survive: {rendered}"
);
assert!(
!rendered.contains("proxy"),
"an answered 503 must not be attributed to a proxy: {rendered}"
);
}
#[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_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_status(StatusCode::INTERNAL_SERVER_ERROR, "");
assert!(matches!(err, BoxliteError::Internal(_)));
}
#[test]
fn bare_auth_status_routes_to_config() {
let err = map_status(StatusCode::UNAUTHORIZED, "no token");
assert!(matches!(err, BoxliteError::Config(_)));
let err = map_status(StatusCode::FORBIDDEN, "wrong scope");
assert!(matches!(err, BoxliteError::Config(_)));
}
#[test]
fn bare_404_is_not_found() {
let err = map_status(StatusCode::NOT_FOUND, "");
assert!(matches!(err, BoxliteError::NotFound(_)));
}
#[test]
fn codeless_flat_body_is_classified_by_status() {
let cases: &[BaselineRow] = &[
(
400,
|e| matches!(e, BoxliteError::InvalidArgument(_)),
"InvalidArgument",
),
(404, |e| matches!(e, BoxliteError::NotFound(_)), "NotFound"),
(
409,
|e| matches!(e, BoxliteError::InvalidState(_)),
"InvalidState",
),
(
408,
|e| matches!(e, BoxliteError::Internal(m) if m.starts_with("server timed out:")),
"Internal(server timed out)",
),
(
428,
|e| matches!(e, BoxliteError::InvalidState(_)),
"InvalidState",
),
(
429,
|e| matches!(e, BoxliteError::ResourceExhausted(_)),
"ResourceExhausted",
),
];
for (status, is_expected, name) in cases {
let text = format!(
r#"{{"path":"/api/v1/org/boxes","timestamp":"2026-01-01T00:00:00.000Z","statusCode":{status},"error":"Whatever","message":"boom-{status}"}}"#
);
let err = map_http_body(StatusCode::from_u16(*status).unwrap(), &text);
assert!(
is_expected(&err),
"codeless {status} should map to {name}, got {err:?}"
);
assert!(
err.to_string().contains(&format!("boom-{status}")),
"message must survive: {err:?}"
);
}
}
#[test]
fn explicit_code_overrides_the_status_baseline() {
let text =
r#"{"statusCode":409,"error":"Conflict","message":"dup","code":"already_exists"}"#;
let err = map_http_body(StatusCode::CONFLICT, text);
assert!(matches!(err, BoxliteError::AlreadyExists(_)), "got {err:?}");
}
#[test]
fn nested_codeless_envelope_keeps_its_message() {
let bodies = [
r#"{"error":{"message":"cpu 200 exceeds the limit","type":"HttpError"}}"#,
r#"{"error":{"message":"cpu 200 exceeds the limit"}}"#,
];
for text in bodies {
let err = map_http_body(StatusCode::BAD_REQUEST, text);
assert!(
matches!(err, BoxliteError::InvalidArgument(_)),
"codeless 400 is a caller error: {err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("cpu 200 exceeds the limit"),
"server message must survive: {rendered}"
);
assert!(
!rendered.contains('{'),
"the raw body must not leak into the message: {rendered}"
);
}
}
#[test]
fn codeless_5xx_is_the_servers_own_fault_not_a_proxys() {
let text = r#"{"statusCode":503,"error":"Service Unavailable","message":"Service is currently under maintenance"}"#;
let err = map_http_body(StatusCode::SERVICE_UNAVAILABLE, text);
assert!(
matches!(err, BoxliteError::Internal(_)),
"an answered 503 is not a transport failure: {err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("under maintenance"),
"server message must survive: {rendered}"
);
assert!(
!rendered.contains("proxy"),
"an answered 503 must not be attributed to a proxy: {rendered}"
);
}
#[test]
fn statuses_without_a_codeless_producer_get_no_baseline() {
for status in [410u16, 422] {
let text = format!(
r#"{{"statusCode":{status},"error":"Whatever","message":"boom-{status}"}}"#
);
let err = map_http_body(StatusCode::from_u16(status).unwrap(), &text);
assert!(
matches!(err, BoxliteError::Internal(_)),
"codeless {status} must not be given a baseline variant: {err:?}"
);
assert!(
err.to_string().contains(&format!("boom-{status}")),
"message must survive: {err:?}"
);
}
}
#[test]
fn bare_gin_error_body_keeps_its_sentence() {
let err = map_http_body(
StatusCode::NOT_FOUND,
r#"{"error":"execution e1 not found"}"#,
);
assert!(matches!(err, BoxliteError::NotFound(_)), "got {err:?}");
let rendered = err.to_string();
assert!(
rendered.contains("execution e1 not found"),
"the sentence must survive: {rendered}"
);
assert!(
!rendered.contains('{'),
"the raw body must not leak into the message: {rendered}"
);
}
}