From 33fecd547047ff7bc6b8046b964fe6c7aa4e005a Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Wed, 1 Jul 2026 17:06:49 +0000 Subject: [PATCH] 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_-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. --- crates/alknet-http/src/gateway/error.rs | 269 ++++++++++++++++++++++++ crates/alknet-http/src/gateway/mod.rs | 2 +- 2 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 crates/alknet-http/src/gateway/error.rs diff --git a/crates/alknet-http/src/gateway/error.rs b/crates/alknet-http/src/gateway/error.rs new file mode 100644 index 0000000..b07674e --- /dev/null +++ b/crates/alknet-http/src/gateway/error.rs @@ -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::() + .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); + } +} \ No newline at end of file diff --git a/crates/alknet-http/src/gateway/mod.rs b/crates/alknet-http/src/gateway/mod.rs index 941bfc0..0154947 100644 --- a/crates/alknet-http/src/gateway/mod.rs +++ b/crates/alknet-http/src/gateway/mod.rs @@ -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;