Skip to main content

gateway_runtime/
errors.rs

1//! # Errors
2//!
3//! ## Purpose
4//! Defines the domain-specific error types for the gateway and utilities for mapping
5//! gRPC status codes to HTTP status codes. This module ensures that internal errors
6//! and upstream gRPC errors are correctly translated into HTTP responses.
7//!
8//! ## Scope
9//! This module defines:
10//! -   `GatewayError`: The primary error type representing failures within the gateway or upstream interactions.
11//! -   `map_code_to_status`: A utility to convert gRPC `Code`s to HTTP `StatusCode`s.
12//! -   `handle_error`: A utility to construct a JSON error response from a `tonic::Status`.
13//!
14//! ## Position in the Architecture
15//! Errors originating from the codec, router, or upstream gRPC calls are captured as `GatewayError`.
16//! The generated code uses `handle_error` to transform these errors into standard HTTP responses
17//! before sending them to the client.
18//!
19//! ## Design Constraints
20//! -   **Dual-Mode Error Handling**: When `std` is enabled, it leverages `thiserror` for ergonomic error definition.
21//!     In `no_std` environments, it falls back to a manual implementation using `alloc`.
22
23#[allow(unused_imports)]
24use crate::alloc;
25use crate::BoxBody;
26use bytes::Bytes;
27use http::{Response, StatusCode};
28use http_body_util::{BodyExt, Full};
29use tonic::Code;
30
31#[cfg(feature = "std")]
32use thiserror::Error;
33
34/// Domain-specific errors for the gateway.
35///
36/// This enum encapsulates various failure modes including serialization issues,
37/// upstream gRPC errors, and HTTP protocol violations.
38#[cfg(feature = "std")]
39#[derive(Debug, Error)]
40pub enum GatewayError {
41    /// Represents a failure during message serialization or deserialization.
42    #[error("failed to serialize/deserialize message: {0}")]
43    Encoding(#[source] Box<dyn std::error::Error + Send + Sync>),
44
45    /// Represents an error returned by the upstream gRPC service.
46    #[error("upstream gRPC error: {0}")]
47    Upstream(#[from] tonic::Status),
48
49    /// Represents an error within the HTTP protocol handling (e.g., building a response).
50    #[error("HTTP protocol error: {0}")]
51    Http(#[from] http::Error),
52
53    /// Represents an error with a specific HTTP status code and message.
54    #[error("HTTP error {0}: {1}")]
55    Custom(StatusCode, String),
56
57    /// Indicates that the requested HTTP method is not allowed for the path.
58    #[error("Method not allowed")]
59    MethodNotAllowed,
60
61    /// Indicates that no route matched the request path.
62    #[error("Not found")]
63    NotFound,
64}
65
66#[cfg(not(feature = "std"))]
67#[derive(Debug)]
68pub enum GatewayError {
69    Encoding(alloc::string::String),
70    Upstream(tonic::Status),
71    Http(http::Error),
72    Custom(StatusCode, alloc::string::String),
73    MethodNotAllowed,
74    NotFound,
75}
76
77#[cfg(not(feature = "std"))]
78impl core::fmt::Display for GatewayError {
79    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        match self {
81            GatewayError::Encoding(e) => {
82                write!(f, "failed to serialize/deserialize message: {}", e)
83            }
84            GatewayError::Upstream(s) => write!(f, "upstream gRPC error: {}", s),
85            GatewayError::Http(e) => write!(f, "HTTP protocol error: {}", e),
86            GatewayError::Custom(c, m) => write!(f, "HTTP error {}: {}", c, m),
87            GatewayError::MethodNotAllowed => write!(f, "Method not allowed"),
88            GatewayError::NotFound => write!(f, "Not found"),
89        }
90    }
91}
92
93/// Maps a gRPC status code to an HTTP status code.
94///
95/// Adheres to the canonical mapping defined in `google.rpc.Code`.
96///
97/// # Parameters
98/// *   `code`: The gRPC status code.
99///
100/// # Returns
101/// The corresponding `http::StatusCode`.
102pub fn map_code_to_status(code: Code) -> StatusCode {
103    match code {
104        Code::Ok => StatusCode::OK,
105        Code::Cancelled => StatusCode::from_u16(499).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
106        Code::Unknown => StatusCode::INTERNAL_SERVER_ERROR,
107        Code::InvalidArgument => StatusCode::BAD_REQUEST,
108        Code::DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
109        Code::NotFound => StatusCode::NOT_FOUND,
110        Code::AlreadyExists => StatusCode::CONFLICT,
111        Code::PermissionDenied => StatusCode::FORBIDDEN,
112        Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
113        Code::FailedPrecondition => StatusCode::BAD_REQUEST,
114        Code::Aborted => StatusCode::CONFLICT,
115        Code::OutOfRange => StatusCode::BAD_REQUEST,
116        Code::Unimplemented => StatusCode::NOT_IMPLEMENTED,
117        Code::Internal => StatusCode::INTERNAL_SERVER_ERROR,
118        Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
119        Code::DataLoss => StatusCode::INTERNAL_SERVER_ERROR,
120        Code::Unauthenticated => StatusCode::UNAUTHORIZED,
121    }
122}
123
124/// Converts a gRPC status into an HTTP response.
125///
126/// This helper creates a JSON response body containing the error code and message,
127/// falling back to standard HTTP status codes derived from the gRPC status.
128///
129/// # Parameters
130/// *   `status`: The `tonic::Status` to convert.
131///
132/// # Returns
133/// An `http::Response` containing the JSON-encoded error details.
134pub fn handle_error(status: tonic::Status) -> Response<BoxBody> {
135    let http_code = map_code_to_status(status.code());
136
137    // Fallback to JSON error response as per grpc-gateway default behavior
138    let body = serde_json::json!({
139        "code": status.code() as i32,
140        "message": status.message(),
141        "details": []
142    });
143
144    let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
145    let full_body =
146        BodyExt::boxed_unsync(Full::new(Bytes::from(body_bytes)).map_err(|never| match never {}));
147
148    Response::builder()
149        .status(http_code)
150        .header("Content-Type", "application/json")
151        .body(full_body)
152        .unwrap_or_else(|_| {
153            Response::new(BodyExt::boxed_unsync(
154                Full::new(Bytes::new()).map_err(|never| match never {}),
155            ))
156        })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_map_code_to_status() {
165        assert_eq!(map_code_to_status(Code::Ok), StatusCode::OK);
166        assert_eq!(
167            map_code_to_status(Code::InvalidArgument),
168            StatusCode::BAD_REQUEST
169        );
170        assert_eq!(map_code_to_status(Code::NotFound), StatusCode::NOT_FOUND);
171        assert_eq!(
172            map_code_to_status(Code::Unauthenticated),
173            StatusCode::UNAUTHORIZED
174        );
175        assert_eq!(
176            map_code_to_status(Code::PermissionDenied),
177            StatusCode::FORBIDDEN
178        );
179        assert_eq!(
180            map_code_to_status(Code::Unavailable),
181            StatusCode::SERVICE_UNAVAILABLE
182        );
183        assert_eq!(
184            map_code_to_status(Code::Internal),
185            StatusCode::INTERNAL_SERVER_ERROR
186        );
187    }
188
189    #[test]
190    fn test_gateway_error_display() {
191        assert_eq!(GatewayError::NotFound.to_string(), "Not found");
192        assert_eq!(
193            GatewayError::MethodNotAllowed.to_string(),
194            "Method not allowed"
195        );
196    }
197
198    #[test]
199    fn test_gateway_error_http_from() {
200        // http::Error is usually from builder.
201        let res = http::Response::builder().header("bad", "\n").body(()); // invalid header value
202        let err = res.unwrap_err();
203        let ge: GatewayError = err.into();
204        match ge {
205            GatewayError::Http(_) => {}
206            _ => panic!("Expected Http error"),
207        }
208    }
209
210    #[test]
211    fn test_gateway_error_upstream_from() {
212        let status = tonic::Status::new(Code::Internal, "msg");
213        let ge: GatewayError = status.into();
214        matches!(ge, GatewayError::Upstream(_));
215    }
216
217    #[test]
218    fn test_handle_error_ok() {
219        // Ok code should be 200? handle_error takes status.
220        let status = tonic::Status::ok("ok");
221        let resp = handle_error(status);
222        assert_eq!(resp.status(), StatusCode::OK);
223    }
224
225    #[test]
226    fn test_handle_error_json_body() {
227        let status = tonic::Status::new(Code::NotFound, "missing");
228        let resp = handle_error(status);
229        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
230        assert_eq!(
231            resp.headers().get("content-type").unwrap(),
232            "application/json"
233        );
234        // Body content check skipped (boxed)
235    }
236
237    #[test]
238    fn test_map_code_cancelled() {
239        // 499 or 500
240        let s = map_code_to_status(Code::Cancelled);
241        assert!(s.as_u16() == 499 || s == StatusCode::INTERNAL_SERVER_ERROR);
242    }
243}