|
|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|