From f6e72d52e655b84644dfab02e803d0c9deb38c19 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Sun, 23 Aug 2026 09:28:30 +0200 Subject: [PATCH] transport: add data_frame_budget to client Endpoint ## Motivation h2 0.4.18 added a client-side budget (client::Builder::data_frame_budget) that guards a connection against floods of small DATA frames, closing it with GOAWAY(ENHANCE_YOUR_CALM) once the budget is exhausted. This is a correctness fix in h2 (see hyperium/h2 CHANGELOG.md 0.4.16-0.4.18), but its default of 25,600 bytes can be exhausted by well-behaved peers: a server-streaming RPC that emits one small message per frame -- one HTTP/2 DATA frame per item in a long stream -- can trip the guard partway through, even though both sides are individually within the HTTP/2 spec. tonic already exposes sibling h2 client settings on Endpoint (max_frame_size, initial_stream_window_size, http2_header_table_size, ...), but had no way to raise this new budget, so callers hitting the guard had no mitigation short of pinning h2 below 0.4.16. This depends on hyperium/hyper exposing the setting on client::conn::http2::Builder, which it does not yet as of hyper 1.11.0 (see the sibling `expose-data-frame-budget` branch on hyperium/hyper); this change should land after that one merges and releases. ## Solution Add a new `data_frame_budget` field to `Endpoint`, mirroring the `max_frame_size` pattern: - Add `data_frame_budget: Option` field to `Endpoint` - Add public `pub fn data_frame_budget(self, budget: impl Into>) -> Self` builder method - Wire the field through to hyper's `Builder::data_frame_budget()` in connection.rs - Include an integration test verifying the setting is accepted and a basic RPC still completes end-to-end (h2's own test suite covers the budget's flood-detection behavior; this test only proves tonic's plumbing) Co-Authored-By: Claude Fable 5 --- .../tests/data_frame_budget.rs | 79 +++++++++++++++++++ tonic/src/transport/channel/endpoint.rs | 33 ++++++++ .../transport/channel/service/connection.rs | 4 + 3 files changed, 116 insertions(+) create mode 100644 tests/integration_tests/tests/data_frame_budget.rs diff --git a/tests/integration_tests/tests/data_frame_budget.rs b/tests/integration_tests/tests/data_frame_budget.rs new file mode 100644 index 000000000..012f6d36d --- /dev/null +++ b/tests/integration_tests/tests/data_frame_budget.rs @@ -0,0 +1,79 @@ +/* + * + * Copyright 2025 gRPC authors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +use std::time::Duration; + +use integration_tests::pb::{test_client, test_server, Input, Output}; +use tokio::sync::oneshot; +use tonic::{ + transport::{Endpoint, Server}, + Request, Response, Status, +}; + +#[tokio::test] +async fn data_frame_budget_on_client_endpoint() { + struct Svc; + + #[tonic::async_trait] + impl test_server::Test for Svc { + async fn unary_call(&self, _: Request) -> Result, Status> { + Ok(Response::new(Output {})) + } + } + + let svc = test_server::TestServer::new(Svc); + + let (tx, rx) = oneshot::channel::<()>(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = format!("http://{}", listener.local_addr().unwrap()); + + let jh = tokio::spawn(async move { + let listener = + tonic::transport::server::TcpIncoming::from(listener).with_nodelay(Some(true)); + Server::builder() + .add_service(svc) + .serve_with_incoming_shutdown(listener, async { drop(rx.await) }) + .await + .unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // 10x h2's own default (25,600 bytes as of h2 v0.4.18), matching the + // budget a caller would raise this to when receiving streams made of + // many small DATA frames. + let channel = Endpoint::from_shared(addr) + .unwrap() + .data_frame_budget(256_000usize) + .connect() + .await + .unwrap(); + + let mut client = test_client::TestClient::new(channel); + + client.unary_call(Request::new(Input {})).await.unwrap(); + + tx.send(()).unwrap(); + jh.await.unwrap(); +} diff --git a/tonic/src/transport/channel/endpoint.rs b/tonic/src/transport/channel/endpoint.rs index 984b639b8..0613e9ece 100644 --- a/tonic/src/transport/channel/endpoint.rs +++ b/tonic/src/transport/channel/endpoint.rs @@ -67,6 +67,7 @@ pub struct Endpoint { pub(crate) init_stream_window_size: Option, pub(crate) init_connection_window_size: Option, pub(crate) max_frame_size: Option, + pub(crate) data_frame_budget: Option, pub(crate) tcp_keepalive: Option, pub(crate) tcp_keepalive_interval: Option, pub(crate) tcp_keepalive_retries: Option, @@ -117,6 +118,7 @@ impl Endpoint { init_stream_window_size: None, init_connection_window_size: None, max_frame_size: None, + data_frame_budget: None, tcp_keepalive: None, tcp_keepalive_interval: None, tcp_keepalive_retries: None, @@ -148,6 +150,7 @@ impl Endpoint { init_stream_window_size: None, init_connection_window_size: None, max_frame_size: None, + data_frame_budget: None, tcp_keepalive: None, tcp_keepalive_interval: None, tcp_keepalive_retries: None, @@ -533,6 +536,36 @@ impl Endpoint { } } + /// Sets a connection-level budget for limiting memory overhead from + /// received small DATA frames. + /// + /// This guards against `GOAWAY(ENHANCE_YOUR_CALM)` disconnects on + /// streams that receive many small DATA frames back-to-back, such as a + /// server-streaming RPC that emits one small message per frame: HTTP/2 + /// flow control accounts for DATA payload bytes but not the per-frame + /// buffering overhead, so an excessive number of small frames can + /// consume disproportionate memory relative to the bytes they carry. + /// + /// Passing `None` will do nothing. + /// + /// If not set, will default from underlying transport. As of `h2` + /// v0.4.18, that default is 25,600 bytes. + /// + /// # Example + /// + /// ``` + /// # use tonic::transport::Endpoint; + /// # let builder = Endpoint::from_static("https://example.com"); + /// let endpoint = builder.data_frame_budget(256_000usize); + /// ``` + #[must_use] + pub fn data_frame_budget(self, budget: impl Into>) -> Self { + Endpoint { + data_frame_budget: budget.into(), + ..self + } + } + /// Sets the executor used to spawn async tasks. /// /// Uses `tokio::spawn` by default. diff --git a/tonic/src/transport/channel/service/connection.rs b/tonic/src/transport/channel/service/connection.rs index 24211ba0b..fddf893b8 100644 --- a/tonic/src/transport/channel/service/connection.rs +++ b/tonic/src/transport/channel/service/connection.rs @@ -67,6 +67,10 @@ impl Connection { settings.max_frame_size(val); } + if let Some(val) = endpoint.data_frame_budget { + settings.data_frame_budget(val); + } + if let Some(val) = endpoint.http2_keep_alive_timeout { settings.keep_alive_timeout(val); }