Skip to main content

rskit_server/
error_layer.rs

1//! Tower layer that intercepts gRPC responses and enriches error statuses
2//! with structured [`rskit_errors::AppError`] details.
3//!
4//! Applied automatically by [`GrpcServerBuilder`] so service implementations
5//! can use `rskit_grpc::app_error_to_status` and the canonical RFC 9457 JSON
6//! error body is always present in the status details.
7
8use std::future::Future;
9use std::pin::Pin;
10use std::task::{Context, Poll};
11
12use http::{Request, Response};
13use tonic::body::Body;
14use tower::{Layer, Service};
15
16use rskit_errors::{AppError, ProblemDetail};
17use rskit_grpc::grpc_code_to_error_code;
18
19// ── Layer ────────────────────────────────────────────────────────────────────
20
21/// A [`Layer`] that wraps each gRPC service to ensure every error
22/// `tonic::Status` carries structured RFC 9457 JSON details.
23#[derive(Debug, Clone, Default)]
24pub struct ErrorLayer;
25
26impl ErrorLayer {
27    /// Create a new `ErrorLayer`.
28    pub fn new() -> Self {
29        Self
30    }
31}
32
33impl<S> Layer<S> for ErrorLayer {
34    type Service = ErrorService<S>;
35
36    fn layer(&self, inner: S) -> Self::Service {
37        ErrorService { inner }
38    }
39}
40
41// ── Service ──────────────────────────────────────────────────────────────────
42
43/// The [`Service`] produced by [`ErrorLayer`].
44///
45/// It forwards requests to the inner service and, if the response indicates a
46/// gRPC error (via the `grpc-status` header) without structured details, it
47/// enriches the trailers with canonical RFC 9457 error JSON.
48#[derive(Debug, Clone)]
49pub struct ErrorService<S> {
50    inner: S,
51}
52
53impl<S: tonic::server::NamedService> tonic::server::NamedService for ErrorService<S> {
54    const NAME: &'static str = S::NAME;
55}
56
57impl<S> Service<Request<Body>> for ErrorService<S>
58where
59    S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
60    S::Future: Send + 'static,
61    S::Error: Send + 'static,
62{
63    type Response = S::Response;
64    type Error = S::Error;
65    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
66
67    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
68        self.inner.poll_ready(cx)
69    }
70
71    fn call(&mut self, req: Request<Body>) -> Self::Future {
72        let mut inner = self.inner.clone();
73        Box::pin(async move {
74            let response = inner.call(req).await?;
75            Ok(enrich_error_response(response))
76        })
77    }
78}
79
80/// If the response carries a `grpc-status` header indicating an error but no
81/// structured details in `grpc-status-details-bin`, synthesise them from the
82/// status code and message.
83fn enrich_error_response(response: Response<Body>) -> Response<Body> {
84    // Only process error responses (grpc-status != 0)
85    let status_code = response
86        .headers()
87        .get("grpc-status")
88        .and_then(|v| v.to_str().ok())
89        .and_then(|v| v.parse::<i32>().ok())
90        .unwrap_or(0);
91
92    if status_code == 0 {
93        return response;
94    }
95
96    // If details are already present, leave the response as-is — the service
97    // already attached them through rskit-grpc status mapping.
98    if response.headers().contains_key("grpc-status-details-bin") {
99        return response;
100    }
101
102    // Synthesise RFC 9457 ProblemDetail from the raw code + message.
103    let grpc_message = response
104        .headers()
105        .get("grpc-message")
106        .and_then(|v| v.to_str().ok())
107        .unwrap_or("unknown error")
108        .to_string();
109
110    let code = tonic::Code::from_i32(status_code);
111    let error_code = grpc_code_to_error_code(code);
112    let app_err = AppError::new(error_code, grpc_message);
113    let problem = ProblemDetail::from(&app_err);
114
115    if let Ok(json_bytes) = serde_json::to_vec(&problem) {
116        let encoded = base64_encode(&json_bytes);
117        let (mut parts, body) = response.into_parts();
118        if let Ok(val) = http::HeaderValue::from_str(&encoded) {
119            parts.headers.insert("grpc-status-details-bin", val);
120        }
121        Response::from_parts(parts, body)
122    } else {
123        response
124    }
125}
126
127/// Base64-encode bytes (standard base64, no padding trimming).
128fn base64_encode(data: &[u8]) -> String {
129    use base64::Engine;
130    base64::engine::general_purpose::STANDARD.encode(data)
131}
132
133// ── Tests ────────────────────────────────────────────────────────────────────
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use rskit_errors::ErrorCode;
139
140    #[test]
141    fn enrich_skips_ok_response() {
142        let resp = Response::builder()
143            .header("grpc-status", "0")
144            .body(Body::default())
145            .unwrap();
146        let result = enrich_error_response(resp);
147        assert!(!result.headers().contains_key("grpc-status-details-bin"));
148    }
149
150    #[test]
151    fn enrich_skips_when_details_already_present() {
152        let resp = Response::builder()
153            .header("grpc-status", "5")
154            .header("grpc-status-details-bin", "existing")
155            .body(Body::default())
156            .unwrap();
157        let result = enrich_error_response(resp);
158        assert_eq!(
159            result.headers().get("grpc-status-details-bin").unwrap(),
160            "existing"
161        );
162    }
163
164    #[test]
165    fn enrich_adds_problem_detail_for_error_without_details() {
166        let resp = Response::builder()
167            .header("grpc-status", "5")
168            .header("grpc-message", "user not found")
169            .body(Body::default())
170            .unwrap();
171        let result = enrich_error_response(resp);
172        assert!(result.headers().contains_key("grpc-status-details-bin"));
173
174        let encoded = result
175            .headers()
176            .get("grpc-status-details-bin")
177            .unwrap()
178            .to_str()
179            .unwrap();
180        use base64::Engine;
181        let decoded = base64::engine::general_purpose::STANDARD
182            .decode(encoded)
183            .unwrap();
184        let pd: ProblemDetail = serde_json::from_slice(&decoded).unwrap();
185        assert_eq!(pd.code, ErrorCode::NotFound);
186        assert_eq!(pd.detail, "user not found");
187        assert_eq!(pd.status, 404);
188        assert!(pd.error_type.ends_with("not-found"));
189    }
190
191    #[test]
192    fn enrich_handles_missing_grpc_status_header() {
193        let resp = Response::builder().body(Body::default()).unwrap();
194        let result = enrich_error_response(resp);
195        assert!(!result.headers().contains_key("grpc-status-details-bin"));
196    }
197
198    #[test]
199    fn error_layer_default_and_new_are_equivalent() {
200        let _l1 = ErrorLayer;
201        let _l2 = ErrorLayer::new();
202    }
203
204    #[tokio::test]
205    async fn layer_wraps_service_and_enriches_error_response() {
206        use tower::ServiceExt;
207
208        let inner = tower::service_fn(|_req: Request<Body>| async {
209            Ok::<_, std::convert::Infallible>(
210                Response::builder()
211                    .header("grpc-status", "14")
212                    .header("grpc-message", "backend unavailable")
213                    .body(Body::default())
214                    .unwrap(),
215            )
216        });
217        let service = ErrorLayer::new().layer(inner);
218
219        let result = service
220            .oneshot(Request::new(Body::default()))
221            .await
222            .expect("service response");
223
224        assert!(result.headers().contains_key("grpc-status-details-bin"));
225    }
226}