feat(http): implement CallError-to-HTTP error mapping (ADR-023)
Add gateway/error.rs with call_error_to_http_status, call_error_to_http_status_with_identity, and call_error_to_http_response. Maps the five protocol codes (NOT_FOUND/FORBIDDEN/INVALID_INPUT/TIMEOUT/ INTERNAL) to fixed HTTP statuses, splits FORBIDDEN into 401 (no identity) / 403 (identity present), maps HTTP_<status>-prefixed operation-level codes to the status number (from_openapi fidelity), and defaults unknown operation-level codes to 500. Retryable 503/429 errors carry a Retry-After header when details.retry_after is present.
This commit is contained in:
269
crates/alknet-http/src/gateway/error.rs
Normal file
269
crates/alknet-http/src/gateway/error.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! CallError → HTTP status/response mapping (ADR-023).
|
||||
//!
|
||||
//! See `docs/architecture/crates/http/http-server.md` §"Error Mapping" and
|
||||
//! ADR-023 for the protocol-level vs operation-level code distinction.
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use serde_json::Value;
|
||||
|
||||
use alknet_call::protocol::wire::CallError;
|
||||
use alknet_core::auth::Identity;
|
||||
|
||||
const PROTOCOL_CODE_NOT_FOUND: &str = "NOT_FOUND";
|
||||
const PROTOCOL_CODE_FORBIDDEN: &str = "FORBIDDEN";
|
||||
const PROTOCOL_CODE_INVALID_INPUT: &str = "INVALID_INPUT";
|
||||
const PROTOCOL_CODE_TIMEOUT: &str = "TIMEOUT";
|
||||
const PROTOCOL_CODE_INTERNAL: &str = "INTERNAL";
|
||||
|
||||
const HTTP_PREFIX: &str = "HTTP_";
|
||||
|
||||
const STATUS_NOT_FOUND: u16 = 404;
|
||||
const STATUS_UNAUTHORIZED: u16 = 401;
|
||||
const STATUS_FORBIDDEN: u16 = 403;
|
||||
const STATUS_UNPROCESSABLE: u16 = 422;
|
||||
const STATUS_TIMEOUT: u16 = 504;
|
||||
const STATUS_INTERNAL: u16 = 500;
|
||||
|
||||
const RETRY_AFTER_STATUSES: &[u16] = &[429, 503];
|
||||
|
||||
pub fn call_error_to_http_status(error: &CallError) -> u16 {
|
||||
call_error_to_http_status_with_identity(error, None)
|
||||
}
|
||||
|
||||
pub fn call_error_to_http_status_with_identity(error: &CallError, identity: Option<&Identity>) -> u16 {
|
||||
match error.code.as_str() {
|
||||
PROTOCOL_CODE_NOT_FOUND => STATUS_NOT_FOUND,
|
||||
PROTOCOL_CODE_FORBIDDEN => {
|
||||
if identity.is_some() {
|
||||
STATUS_FORBIDDEN
|
||||
} else {
|
||||
STATUS_UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
PROTOCOL_CODE_INVALID_INPUT => STATUS_UNPROCESSABLE,
|
||||
PROTOCOL_CODE_TIMEOUT => STATUS_TIMEOUT,
|
||||
PROTOCOL_CODE_INTERNAL => STATUS_INTERNAL,
|
||||
code if code.starts_with(HTTP_PREFIX) => code[HTTP_PREFIX.len()..]
|
||||
.parse::<u16>()
|
||||
.unwrap_or(STATUS_INTERNAL),
|
||||
_ => STATUS_INTERNAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn call_error_to_http_response(error: &CallError) -> Response {
|
||||
let status_code = call_error_to_http_status(error);
|
||||
let status = status_code_from_u16(status_code);
|
||||
let body = serde_json::to_value(error).unwrap_or(Value::Null);
|
||||
|
||||
let retry_after = retry_after_value(error, status_code);
|
||||
|
||||
if let Some(retry_after) = retry_after {
|
||||
let header_value = HeaderValue::from_str(&retry_after)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("0"));
|
||||
(status, [(header::RETRY_AFTER, header_value)], Json(body)).into_response()
|
||||
} else {
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code_from_u16(code: u16) -> StatusCode {
|
||||
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn retry_after_value(error: &CallError, status_code: u16) -> Option<String> {
|
||||
if !error.retryable || !RETRY_AFTER_STATUSES.contains(&status_code) {
|
||||
return None;
|
||||
}
|
||||
error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("retry_after"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
error
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.get("retry_after"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alknet_core::auth::Identity;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn identity() -> Identity {
|
||||
Identity {
|
||||
id: "caller".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_maps_to_404() {
|
||||
let error = CallError::not_found("fs/missing");
|
||||
assert_eq!(call_error_to_http_status(&error), 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_maps_to_422() {
|
||||
let error = CallError::invalid_input("bad input");
|
||||
assert_eq!(call_error_to_http_status(&error), 422);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_maps_to_504() {
|
||||
let error = CallError::timeout("timed out");
|
||||
assert_eq!(call_error_to_http_status(&error), 504);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_maps_to_500() {
|
||||
let error = CallError::internal("boom");
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_with_none_identity_maps_to_401() {
|
||||
let error = CallError::forbidden("auth required");
|
||||
assert_eq!(call_error_to_http_status_with_identity(&error, None), 401);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_with_some_identity_maps_to_403() {
|
||||
let error = CallError::forbidden("insufficient scopes");
|
||||
let id = identity();
|
||||
assert_eq!(call_error_to_http_status_with_identity(&error, Some(&id)), 403);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_default_uses_401() {
|
||||
let error = CallError::forbidden("auth required");
|
||||
assert_eq!(call_error_to_http_status(&error), 401);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_level_code_with_http_prefix_maps_to_status_number() {
|
||||
let error = CallError::new("HTTP_404", "not found", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_429_maps_to_429_not_collided_with_protocol_codes() {
|
||||
let error = CallError::new("HTTP_429", "rate limited", true);
|
||||
assert_eq!(call_error_to_http_status(&error), 429);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_level_domain_code_without_http_status_maps_to_500() {
|
||||
let error = CallError::new("FILE_NOT_FOUND", "file missing", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_level_code_with_http_prefix_422_maps_to_422() {
|
||||
let error = CallError::new("HTTP_422", "validation error", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 422);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_protocol_like_code_maps_to_500() {
|
||||
let error = CallError::new("SOMETHING_NEW", "unexpected", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_prefix_with_non_numeric_suffix_maps_to_500() {
|
||||
let error = CallError::new("HTTP_NOTANUMBER", "bad", false);
|
||||
assert_eq!(call_error_to_http_status(&error), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_error_to_http_response_has_status_and_json_body() {
|
||||
let error = CallError::not_found("fs/missing");
|
||||
let response = call_error_to_http_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::from_u16(404).unwrap());
|
||||
assert_eq!(
|
||||
response.headers().get(axum::http::header::CONTENT_TYPE),
|
||||
Some(&HeaderValue::from_static("application/json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_503_adds_retry_after_header_when_details_present() {
|
||||
let error = CallError::new("HTTP_503", "slow down", true)
|
||||
.with_details(serde_json::json!({ "retry_after": "5" }));
|
||||
let response = call_error_to_http_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::from_u16(503).unwrap());
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(axum::http::header::RETRY_AFTER)
|
||||
.expect("Retry-After header present");
|
||||
assert_eq!(retry_after.to_str().unwrap(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_503_no_retry_after_in_details_omits_header() {
|
||||
let error = CallError::new("HTTP_503", "slow down", true);
|
||||
let response = call_error_to_http_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::from_u16(503).unwrap());
|
||||
assert!(response.headers().get(axum::http::header::RETRY_AFTER).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_503_omits_retry_after_header() {
|
||||
let error = CallError::new("HTTP_503", "down", false)
|
||||
.with_details(serde_json::json!({ "retry_after": "5" }));
|
||||
let response = call_error_to_http_response(&error);
|
||||
assert!(response.headers().get(axum::http::header::RETRY_AFTER).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_429_adds_retry_after_header() {
|
||||
let error = CallError::new("HTTP_429", "too fast", true)
|
||||
.with_details(serde_json::json!({ "retry_after": "10" }));
|
||||
let response = call_error_to_http_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::from_u16(429).unwrap());
|
||||
assert!(response
|
||||
.headers()
|
||||
.get(axum::http::header::RETRY_AFTER)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_timeout_504_omits_retry_after_header() {
|
||||
let error = CallError::timeout("timed out");
|
||||
let response = call_error_to_http_response(&error);
|
||||
assert_eq!(response.status(), StatusCode::from_u16(504).unwrap());
|
||||
assert!(response.headers().get(axum::http::header::RETRY_AFTER).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_numeric_u64_is_used() {
|
||||
let error = CallError::new("HTTP_503", "slow", true)
|
||||
.with_details(serde_json::json!({ "retry_after": 30 }));
|
||||
let response = call_error_to_http_response(&error);
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(axum::http::header::RETRY_AFTER)
|
||||
.expect("Retry-After present");
|
||||
assert_eq!(retry_after.to_str().unwrap(), "30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_identity_function_consistent_with_default_for_non_forbidden() {
|
||||
let error = CallError::not_found("op");
|
||||
let id = identity();
|
||||
assert_eq!(
|
||||
call_error_to_http_status_with_identity(&error, Some(&id)),
|
||||
404
|
||||
);
|
||||
assert_eq!(call_error_to_http_status_with_identity(&error, None), 404);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@
|
||||
//! `/subscribe`) are the sole HTTP invoke path (ADR-042/047). See
|
||||
//! `docs/architecture/crates/http/http-server.md`.
|
||||
|
||||
// TODO: implement
|
||||
pub mod error;
|
||||
|
||||
Reference in New Issue
Block a user