saluki_io/net/server/
grpc.rs

1//! gRPC support for [`HttpServer`][super::http::HttpServer].
2//!
3//! There is no dedicated gRPC server here, because gRPC does not need one: it is HTTP/2 plus a route naming
4//! convention (`/<package>.<Service>/<Method>`) and a trailer-carried status code. `tonic`'s [`Routes`] is an
5//! [`axum::Router`] underneath, and [`HttpServer`][super::http::HttpServer] already serves HTTP/2, so a gRPC service
6//! is served by handing its routes to an [`HttpServer`][super::http::HttpServer] like any other route set.
7//!
8//! What that leaves are the parts route matching can't cover on its own: a request that matches nothing has to be
9//! answered in whichever protocol the caller spoke, and a request that carries a deadline has to be held to it.
10//! [`merge_grpc_routes`] wires up both.
11//!
12//! Most callers never reach for this module directly. Handing services to
13//! [`HttpServer::add_grpc_service`][super::http::HttpServer::add_grpc_service] applies all of it:
14//!
15//! ```no_run
16//! # use saluki_io::net::{server::http::{Http2Config, HttpServer}, ListenAddress};
17//! # fn build<S>(service: S, listen_address: ListenAddress)
18//! # where
19//! #     S: tower::Service<http::Request<tonic::body::Body>, Error = std::convert::Infallible>
20//! #         + tonic::server::NamedService + Clone + Send + Sync + 'static,
21//! #     S::Response: axum::response::IntoResponse,
22//! #     S::Future: Send + 'static,
23//! # {
24//! let _server = HttpServer::from_listen_address(listen_address)
25//!     .add_grpc_service(service)
26//!     .with_http2_only()
27//!     .with_http2_config(Http2Config::grpc_defaults());
28//! # }
29//! ```
30//!
31//! [`merge_grpc_routes`] is for callers that build their own router up-front and hand it over with
32//! [`HttpServer::with_routes`][super::http::HttpServer::with_routes], rather than accumulating routes on the server.
33
34use std::{
35    convert::Infallible,
36    future::Future,
37    pin::Pin,
38    task::{ready, Context, Poll},
39    time::Duration,
40};
41
42use axum::{body::Body, response::IntoResponse as _, Router};
43use http::{header::CONTENT_TYPE, HeaderMap, HeaderValue, Request, Response, StatusCode};
44use pin_project_lite::pin_project;
45use tokio::time::{sleep, Sleep};
46use tonic::{service::Routes, Status};
47use tower::{Layer, Service};
48use tracing::trace;
49
50/// Header a gRPC client uses to communicate the deadline it is holding the server to.
51const GRPC_TIMEOUT_HEADER: &str = "grpc-timeout";
52
53const SECONDS_PER_HOUR: u64 = 60 * 60;
54const SECONDS_PER_MINUTE: u64 = 60;
55
56/// Largest `TimeoutValue` the gRPC specification allows, expressed as a digit count.
57///
58/// Enforcing it is also what keeps the unit conversions below from overflowing.
59const MAX_TIMEOUT_VALUE_DIGITS: usize = 8;
60
61/// Merges gRPC routes into an HTTP router.
62///
63/// The returned router serves both route sets, and answers anything that matches neither with [`unmatched_route`]. The
64/// gRPC routes are additionally wrapped in [`GrpcTimeoutLayer`], so a caller's `grpc-timeout` deadline is enforced.
65///
66/// `tonic` installs its own fallback on [`Routes`] to return gRPC `UNIMPLEMENTED`, and axum refuses to merge two
67/// routers that both define one, so that fallback is dropped in favor of the protocol-aware one. Any fallback set on
68/// `http_router` is dropped for the same reason: register a catch-all route instead if you need one.
69///
70/// # Panics
71///
72/// Panics if the two route sets define the same path, which is [`Router::merge`]'s behavior. In practice this can only
73/// happen if an HTTP route is registered under a path that looks like a gRPC method.
74pub fn merge_grpc_routes(http_router: Router, grpc_routes: Routes) -> Router {
75    // The deadline layer goes on the gRPC routes alone. `grpc-timeout` is a gRPC concept, and an HTTP route that
76    // happens to receive the header has no reason to be held to it.
77    let grpc_router = grpc_routes.into_axum_router().reset_fallback().layer(GrpcTimeoutLayer);
78
79    http_router
80        .reset_fallback()
81        .merge(grpc_router)
82        .fallback(unmatched_route)
83}
84
85/// A [`Layer`] that holds a gRPC request to the deadline it arrived with.
86///
87/// See [`GrpcTimeout`] for what that means in practice.
88#[derive(Clone, Copy, Debug, Default)]
89pub struct GrpcTimeoutLayer;
90
91impl<S> Layer<S> for GrpcTimeoutLayer {
92    type Service = GrpcTimeout<S>;
93
94    fn layer(&self, inner: S) -> Self::Service {
95        GrpcTimeout { inner }
96    }
97}
98
99/// A [`Service`] that bounds how long the inner service has to answer a gRPC request.
100///
101/// A gRPC client states its deadline in the `grpc-timeout` request header. Enforcing it server-side means a request
102/// the caller has already given up on stops consuming resources, rather than running to completion so its response can
103/// be discarded.
104///
105/// A request without the header, or with a header that doesn't parse, is passed through with no deadline. Silently
106/// ignoring a malformed value is what the gRPC specification calls for: a deadline the server can't read is not grounds
107/// for rejecting the request.
108///
109/// An expired deadline is answered with `DEADLINE_EXCEEDED`, which is the code the specification assigns to it. Note
110/// that `tonic`'s own timeout middleware answers with `CANCELLED` instead, an artifact of how it routes the expiry
111/// through its generic error handling.
112///
113/// # Missing
114///
115/// There is no server-side maximum to bound a client that asks for an unreasonably long deadline, because nothing here
116/// needs one yet. Adding it means taking the shorter of the two durations.
117#[derive(Clone, Copy, Debug)]
118pub struct GrpcTimeout<S> {
119    inner: S,
120}
121
122impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for GrpcTimeout<S>
123where
124    S: Service<Request<ReqBody>, Response = Response<ResBody>, Error = Infallible>,
125    ResBody: Default,
126{
127    type Response = Response<ResBody>;
128    type Error = Infallible;
129    type Future = GrpcTimeoutFuture<S::Future>;
130
131    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
132        self.inner.poll_ready(cx)
133    }
134
135    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
136        let deadline = match parse_grpc_timeout(req.headers()) {
137            Ok(deadline) => deadline,
138            Err(value) => {
139                trace!(header = ?value, "Ignoring malformed `grpc-timeout` header.");
140                None
141            }
142        };
143
144        GrpcTimeoutFuture {
145            inner: self.inner.call(req),
146            deadline: deadline.map(sleep),
147        }
148    }
149}
150
151pin_project! {
152    /// Response future for [`GrpcTimeout`].
153    pub struct GrpcTimeoutFuture<F> {
154        #[pin]
155        inner: F,
156
157        #[pin]
158        deadline: Option<Sleep>,
159    }
160}
161
162impl<F, ResBody> Future for GrpcTimeoutFuture<F>
163where
164    F: Future<Output = Result<Response<ResBody>, Infallible>>,
165    ResBody: Default,
166{
167    type Output = Result<Response<ResBody>, Infallible>;
168
169    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
170        let this = self.project();
171
172        // Poll the request first, so that a response which is already ready wins over a deadline that expires on the
173        // same poll. Answering a request we have the answer to is never worse than reporting that it timed out.
174        if let Poll::Ready(result) = this.inner.poll(cx) {
175            return Poll::Ready(result);
176        }
177
178        if let Some(deadline) = this.deadline.as_pin_mut() {
179            ready!(deadline.poll(cx));
180
181            // Returning here drops the inner future, which is what actually cancels the work in flight.
182            return Poll::Ready(Ok(Status::deadline_exceeded(
183                "Deadline expired before operation could complete.",
184            )
185            .into_http()));
186        }
187
188        Poll::Pending
189    }
190}
191
192/// Parses the deadline carried by the `grpc-timeout` header, if there is one.
193///
194/// Returns the offending value when the header is present but doesn't parse, so the caller can report what it saw.
195///
196/// The encoding is `TimeoutValue TimeoutUnit`, where the value is at most eight digits and the unit is one of `H`
197/// (hours), `M` (minutes), `S` (seconds), `m` (milliseconds), `u` (microseconds), or `n` (nanoseconds). See the
198/// [gRPC over HTTP/2 specification][spec].
199///
200/// [spec]: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
201fn parse_grpc_timeout(headers: &HeaderMap) -> Result<Option<Duration>, &HeaderValue> {
202    let Some(value) = headers.get(GRPC_TIMEOUT_HEADER) else {
203        return Ok(None);
204    };
205
206    // `to_str` only succeeds for ASCII, so splitting off the last byte can't land in the middle of a character.
207    let encoded = value.to_str().map_err(|_| value)?;
208    if encoded.is_empty() {
209        return Err(value);
210    }
211    let (timeout_value, timeout_unit) = encoded.split_at(encoded.len() - 1);
212
213    if timeout_value.len() > MAX_TIMEOUT_VALUE_DIGITS {
214        return Err(value);
215    }
216
217    let timeout_value: u64 = timeout_value.parse().map_err(|_| value)?;
218
219    let timeout = match timeout_unit {
220        "H" => Duration::from_secs(timeout_value * SECONDS_PER_HOUR),
221        "M" => Duration::from_secs(timeout_value * SECONDS_PER_MINUTE),
222        "S" => Duration::from_secs(timeout_value),
223        "m" => Duration::from_millis(timeout_value),
224        "u" => Duration::from_micros(timeout_value),
225        "n" => Duration::from_nanos(timeout_value),
226        _ => return Err(value),
227    };
228
229    Ok(Some(timeout))
230}
231
232/// Answers a request that matched no route, in the protocol the caller used.
233///
234/// gRPC callers get the `UNIMPLEMENTED` status they expect, and everyone else gets a plain `404 Not Found`.
235///
236/// Answering a gRPC caller with a bare 404 would mostly work -- the gRPC specification has clients map `404` to
237/// `UNIMPLEMENTED` when no `grpc-status` is present -- but it costs nothing to return the status directly, and doing so
238/// keeps the response identical to what a standalone gRPC server would send.
239pub async fn unmatched_route(headers: HeaderMap) -> Response<Body> {
240    if is_grpc_request(&headers) {
241        Status::unimplemented("").into_http()
242    } else {
243        StatusCode::NOT_FOUND.into_response()
244    }
245}
246
247/// Returns `true` if the given headers indicate a gRPC request.
248///
249/// The check is on the `Content-Type` header rather than the request path, since a request that reaches this point
250/// matched no route and so its path says nothing useful.
251pub fn is_grpc_request(headers: &HeaderMap) -> bool {
252    // We specifically check if the header value _starts_ with `application/grpc` as the gRPC spec allows for additional
253    // suffixes to describe how the payload is encoded (i.e. `application/grpc+proto` when encoded via Protocol Buffers
254    // vs `application/grpc+json` when encoded via JSON for gRPC-Web).
255    headers
256        .get(CONTENT_TYPE)
257        .map(|content_type| content_type.as_bytes())
258        .is_some_and(|content_type| content_type.starts_with(b"application/grpc"))
259}
260
261#[cfg(test)]
262mod tests {
263    use tonic::server::NamedService;
264    use tower::{util::service_fn, ServiceExt as _};
265
266    use super::*;
267
268    fn headers_with_content_type(value: &str) -> HeaderMap {
269        let mut headers = HeaderMap::new();
270        headers.insert(
271            CONTENT_TYPE,
272            HeaderValue::from_str(value).expect("should be a valid header"),
273        );
274        headers
275    }
276
277    /// Reads the gRPC status code off a response, if it carries one.
278    fn grpc_status(response: &Response<Body>) -> Option<&str> {
279        response
280            .headers()
281            .get("grpc-status")
282            .and_then(|value| value.to_str().ok())
283    }
284
285    /// Parses a `grpc-timeout` header value, discarding the offending value on failure.
286    fn parse_timeout(value: &str) -> Result<Option<Duration>, ()> {
287        let mut headers = HeaderMap::new();
288        headers.insert(
289            GRPC_TIMEOUT_HEADER,
290            HeaderValue::from_str(value).expect("should be a valid header"),
291        );
292
293        parse_grpc_timeout(&headers).map_err(|_| ())
294    }
295
296    /// Runs a handler that takes `handler_delay` to answer, behind the deadline layer.
297    async fn call_with_deadline(timeout_header: Option<&str>, handler_delay: Duration) -> Response<Body> {
298        let service = GrpcTimeoutLayer.layer(service_fn(move |_req: Request<Body>| async move {
299            tokio::time::sleep(handler_delay).await;
300            Ok::<_, Infallible>(Response::new(Body::empty()))
301        }));
302
303        let mut request = Request::new(Body::empty());
304        if let Some(timeout_header) = timeout_header {
305            request.headers_mut().insert(
306                GRPC_TIMEOUT_HEADER,
307                HeaderValue::from_str(timeout_header).expect("should be a valid header"),
308            );
309        }
310
311        service.oneshot(request).await.expect("service should not fail")
312    }
313
314    /// A gRPC service that never answers quickly enough to beat a deadline.
315    #[derive(Clone)]
316    struct SlowService;
317
318    impl NamedService for SlowService {
319        const NAME: &'static str = "test.SlowService";
320    }
321
322    impl Service<Request<tonic::body::Body>> for SlowService {
323        type Response = Response<Body>;
324        type Error = Infallible;
325        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
326
327        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
328            Poll::Ready(Ok(()))
329        }
330
331        fn call(&mut self, _req: Request<tonic::body::Body>) -> Self::Future {
332            Box::pin(async {
333                tokio::time::sleep(Duration::from_secs(10)).await;
334                Ok(Response::new(Body::empty()))
335            })
336        }
337    }
338
339    #[test]
340    fn detects_grpc_content_types() {
341        // The bare type and the encoding-suffixed forms are all gRPC.
342        for content_type in ["application/grpc", "application/grpc+proto", "application/grpc+json"] {
343            assert!(
344                is_grpc_request(&headers_with_content_type(content_type)),
345                "'{content_type}' should be detected as gRPC"
346            );
347        }
348    }
349
350    #[test]
351    fn does_not_detect_non_grpc_content_types() {
352        for content_type in ["application/json", "application/x-protobuf", "text/plain"] {
353            assert!(
354                !is_grpc_request(&headers_with_content_type(content_type)),
355                "'{content_type}' should not be detected as gRPC"
356            );
357        }
358
359        assert!(
360            !is_grpc_request(&HeaderMap::new()),
361            "a request without a content type should not be detected as gRPC"
362        );
363    }
364
365    #[tokio::test]
366    async fn unmatched_grpc_request_gets_unimplemented() {
367        // gRPC reports errors as a 200 with a `grpc-status` header. UNIMPLEMENTED is code 12.
368        let response = unmatched_route(headers_with_content_type("application/grpc")).await;
369        assert_eq!(response.status(), StatusCode::OK);
370        assert_eq!(
371            response.headers().get("grpc-status").and_then(|v| v.to_str().ok()),
372            Some("12")
373        );
374    }
375
376    #[tokio::test]
377    async fn unmatched_http_request_gets_not_found() {
378        let response = unmatched_route(headers_with_content_type("application/json")).await;
379        assert_eq!(response.status(), StatusCode::NOT_FOUND);
380        assert!(response.headers().get("grpc-status").is_none());
381    }
382    #[test]
383    fn parses_every_timeout_unit() {
384        assert_eq!(parse_timeout("3H"), Ok(Some(Duration::from_secs(3 * 60 * 60))));
385        assert_eq!(parse_timeout("1M"), Ok(Some(Duration::from_secs(60))));
386        assert_eq!(parse_timeout("42S"), Ok(Some(Duration::from_secs(42))));
387        assert_eq!(parse_timeout("13m"), Ok(Some(Duration::from_millis(13))));
388        assert_eq!(parse_timeout("2u"), Ok(Some(Duration::from_micros(2))));
389        assert_eq!(parse_timeout("82n"), Ok(Some(Duration::from_nanos(82))));
390    }
391
392    #[test]
393    fn parses_the_largest_permitted_timeout_value() {
394        // Eight digits of hours is the ceiling the specification allows, and is what the digit cap exists to keep the
395        // unit conversion from overflowing on.
396        assert_eq!(
397            parse_timeout("99999999H"),
398            Ok(Some(Duration::from_secs(99_999_999 * 60 * 60)))
399        );
400    }
401
402    #[test]
403    fn absent_timeout_header_yields_no_deadline() {
404        assert_eq!(
405            parse_grpc_timeout(&HeaderMap::new()).expect("an absent header should not be an error"),
406            None
407        );
408    }
409
410    #[test]
411    fn rejects_malformed_timeout_values() {
412        // In order: an unknown unit, more digits than the specification allows, a non-numeric value, a unit with no
413        // value, a value with no unit, and an empty header.
414        for value in ["82f", "123456789H", "oneH", "S", "8", ""] {
415            assert!(parse_timeout(value).is_err(), "'{value}' should not parse");
416        }
417    }
418
419    #[tokio::test(start_paused = true)]
420    async fn deadline_expiry_answers_with_deadline_exceeded() {
421        // gRPC reports errors as a 200 with a `grpc-status` header. DEADLINE_EXCEEDED is code 4.
422        let response = call_with_deadline(Some("50m"), Duration::from_secs(10)).await;
423        assert_eq!(response.status(), StatusCode::OK);
424        assert_eq!(grpc_status(&response), Some("4"));
425    }
426
427    #[tokio::test(start_paused = true)]
428    async fn a_response_within_the_deadline_is_passed_through() {
429        let response = call_with_deadline(Some("10S"), Duration::from_millis(50)).await;
430        assert_eq!(response.status(), StatusCode::OK);
431        assert_eq!(grpc_status(&response), None);
432    }
433
434    #[tokio::test(start_paused = true)]
435    async fn a_request_without_a_deadline_is_not_bounded() {
436        let response = call_with_deadline(None, Duration::from_secs(60 * 60)).await;
437        assert_eq!(grpc_status(&response), None);
438    }
439
440    #[tokio::test(start_paused = true)]
441    async fn a_malformed_deadline_is_ignored() {
442        // The specification treats an unreadable deadline as no deadline: it is not grounds for rejecting the request.
443        let response = call_with_deadline(Some("howlong"), Duration::from_secs(60 * 60)).await;
444        assert_eq!(grpc_status(&response), None);
445    }
446
447    #[tokio::test(start_paused = true)]
448    async fn merged_grpc_routes_are_bound_by_their_deadline() {
449        let router = merge_grpc_routes(Router::new(), Routes::new(SlowService));
450        let request = Request::builder()
451            .uri("/test.SlowService/Method")
452            .header(CONTENT_TYPE, "application/grpc")
453            .header(GRPC_TIMEOUT_HEADER, "50m")
454            .body(Body::empty())
455            .expect("should build request");
456
457        let response = router.oneshot(request).await.expect("router should answer");
458        assert_eq!(grpc_status(&response), Some("4"));
459    }
460
461    #[tokio::test(start_paused = true)]
462    async fn merged_http_routes_are_not_bound_by_a_grpc_deadline() {
463        // The deadline layer is attached to the gRPC routes alone, so an HTTP route that happens to receive the header
464        // runs to completion rather than being cut short by a convention it has nothing to do with.
465        let slow_route = axum::routing::get(|| async {
466            tokio::time::sleep(Duration::from_secs(10)).await;
467            "done"
468        });
469        let router = merge_grpc_routes(Router::new().route("/slow", slow_route), Routes::default());
470        let request = Request::builder()
471            .uri("/slow")
472            .header(GRPC_TIMEOUT_HEADER, "50m")
473            .body(Body::empty())
474            .expect("should build request");
475
476        let response = router.oneshot(request).await.expect("router should answer");
477        assert_eq!(response.status(), StatusCode::OK);
478        assert_eq!(grpc_status(&response), None);
479    }
480}