//! 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::() .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 { 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); } }