Fix HTTP/2 requests failing with duplicate Content-Length

When Yaak moved compression, redirect, and multipart handling out of
reqwest, it started explicitly adding Content-Length as an HTTP header.
Over HTTP/2, hyper also sets content-length internally via DATA frame
sizing, causing a duplicate that HTTP/2 servers (like Node.js) reject
with PROTOCOL_ERROR.

Instead of setting Content-Length as an explicit header, carry the
content length through SendableBody::Stream and use http_body::Body's
size_hint() to let hyper handle it automatically for both HTTP/1.1
and HTTP/2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-02-11 11:49:36 -08:00
co-authored by Claude Opus 4.6
parent 26aba6034f
commit e6dbdc1db5
6 changed files with 216 additions and 58 deletions
+114
View File
@@ -0,0 +1,114 @@
/// Integration test to reproduce the HTTP/2 "HTTP/1 specific headers are forbidden" bug.
///
/// Requires a local HTTP/2-only server running on https://localhost:8443
/// Start one with: node h2server.mjs (see tmp dir for the script)
///
/// Run with: cargo test -p yaak-http --test h2_repro -- --nocapture
use reqwest::redirect;
use tokio::sync::mpsc;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::SendableHttpRequest;
use yaak_tls::get_tls_config;
fn build_yaak_client() -> reqwest::Client {
let tls_config = get_tls_config(false, true, None).unwrap();
reqwest::Client::builder()
.redirect(redirect::Policy::none())
.no_gzip()
.no_brotli()
.no_deflate()
.referer(false)
.tls_info(true)
.pool_max_idle_per_host(0)
.use_preconfigured_tls(tls_config)
.build()
.unwrap()
}
async fn send_and_print(name: &str, request: SendableHttpRequest) {
println!("\n=== {} ===", name);
let sender = ReqwestSender::with_client(build_yaak_client());
let (event_tx, mut event_rx) = mpsc::channel(100);
let test_name = name.to_string();
let handle = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
println!(" [{}] {}", test_name, event);
}
});
match sender.send(request, event_tx).await {
Ok(response) => {
println!(" [{}] OK: HTTP {} {:?}", name, response.status, response.version);
}
Err(e) => {
println!(" [{}] FAILED: {}", name, e);
}
}
let _ = handle.await;
}
#[tokio::test]
async fn test_h2_post_with_body() {
// Narrow down: POST with body but no extra headers
send_and_print("post-body-no-headers", SendableHttpRequest {
url: "https://localhost:8443/".to_string(),
method: "POST".to_string(),
headers: vec![],
body: Some(yaak_http::types::SendableBody::Bytes(
bytes::Bytes::from(r#"{"hello":"world"}"#),
)),
options: Default::default(),
}).await;
// POST with body + content-type only
send_and_print("post-body-content-type", SendableHttpRequest {
url: "https://localhost:8443/".to_string(),
method: "POST".to_string(),
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
],
body: Some(yaak_http::types::SendableBody::Bytes(
bytes::Bytes::from(r#"{"hello":"world"}"#),
)),
options: Default::default(),
}).await;
// POST with body + content-length only
send_and_print("post-body-content-length", SendableHttpRequest {
url: "https://localhost:8443/".to_string(),
method: "POST".to_string(),
headers: vec![
("Content-Length".to_string(), "18".to_string()),
],
body: Some(yaak_http::types::SendableBody::Bytes(
bytes::Bytes::from(r#"{"hello":"world"}"#),
)),
options: Default::default(),
}).await;
// POST with body + all typical Yaak headers
send_and_print("post-body-all-yaak-headers", SendableHttpRequest {
url: "https://localhost:8443/".to_string(),
method: "POST".to_string(),
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
("Content-Length".to_string(), "18".to_string()),
("User-Agent".to_string(), "yaak".to_string()),
("Accept".to_string(), "*/*".to_string()),
],
body: Some(yaak_http::types::SendableBody::Bytes(
bytes::Bytes::from(r#"{"hello":"world"}"#),
)),
options: Default::default(),
}).await;
// GET with no body (control — should work)
send_and_print("get-control", SendableHttpRequest {
url: "https://localhost:8443/".to_string(),
method: "GET".to_string(),
headers: vec![],
body: None,
options: Default::default(),
}).await;
}