1use 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
50const GRPC_TIMEOUT_HEADER: &str = "grpc-timeout";
52
53const SECONDS_PER_HOUR: u64 = 60 * 60;
54const SECONDS_PER_MINUTE: u64 = 60;
55
56const MAX_TIMEOUT_VALUE_DIGITS: usize = 8;
60
61pub fn merge_grpc_routes(http_router: Router, grpc_routes: Routes) -> Router {
75 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#[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#[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 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 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 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
192fn 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 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
232pub 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
247pub fn is_grpc_request(headers: &HeaderMap) -> bool {
252 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 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 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 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 #[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 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 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 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 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 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 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 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}