From 612dcf5143d7089c44fe731f80647a7703ae1ae9 Mon Sep 17 00:00:00 2001 From: carodewig <16093297+carodewig@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:29:33 -0400 Subject: [PATCH 1/6] fix: don't enable http1 when http2only is used --- apollo-router/src/services/http/service.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apollo-router/src/services/http/service.rs b/apollo-router/src/services/http/service.rs index e3be9b9eb7..0ee85279f6 100644 --- a/apollo-router/src/services/http/service.rs +++ b/apollo-router/src/services/http/service.rs @@ -201,14 +201,16 @@ impl HttpClientService { let builder = hyper_rustls::HttpsConnectorBuilder::new() .with_tls_config(tls_config) - .https_or_http() - .enable_http1(); + .https_or_http(); let http2 = client_config.experimental_http2.unwrap_or_default(); - let connector = if http2 != Http2Config::Disable { - builder.enable_http2().wrap_connector(http_connector) - } else { - builder.wrap_connector(http_connector) + let connector = match http2 { + Http2Config::Enable => builder + .enable_http1() + .enable_http2() + .wrap_connector(http_connector), + Http2Config::Disable => builder.enable_http1().wrap_connector(http_connector), + Http2Config::Http2Only => builder.enable_http2().wrap_connector(http_connector), }; let http_client = From 5145646493b217450d92b7151e1d73df504425f4 Mon Sep 17 00:00:00 2001 From: carodewig <16093297+carodewig@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:27:42 -0400 Subject: [PATCH 2/6] test: only return OK if request was http2 --- apollo-router/src/services/http/tests.rs | 34 ++++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/apollo-router/src/services/http/tests.rs b/apollo-router/src/services/http/tests.rs index 9ee590258f..674a027631 100644 --- a/apollo-router/src/services/http/tests.rs +++ b/apollo-router/src/services/http/tests.rs @@ -571,22 +571,27 @@ mod h2c_cleartext { // Starts a local server that responds with a default GraphQL response over plain HTTP. async fn emulate_h2c_server(listener: TcpListener) { - async fn handle(_request: http::Request) -> Result, Infallible> { - Ok(http::Response::builder() - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .status(StatusCode::OK) - .body( - serde_json::to_string(&Response { - data: Some(Value::default()), - ..Response::default() - }) - .expect("always valid") - .into(), - ) - .unwrap()) + async fn handle(request: http::Request) -> Result, Infallible> { + let response_builder = + http::Response::builder().header(CONTENT_TYPE, APPLICATION_JSON.essence_str()); + + let response = if request.version() == Version::HTTP_2 { + let response_body = serde_json::to_string(&Response { + data: Some(Value::default()), + ..Response::default() + }); + response_builder + .status(StatusCode::OK) + .body(response_body.unwrap().into()) + } else { + response_builder + .status(StatusCode::HTTP_VERSION_NOT_SUPPORTED) + .body(Body::empty()) + }; + + Ok(response.unwrap()) } - // XXX(@goto-bus-stop): ideally this server would *only* support HTTP 2 and not HTTP 1 serve(listener, handle).await.unwrap(); } @@ -615,6 +620,7 @@ mod h2c_cleartext { r#"{"query":"{ me { name username } }"#, ) .await; + assert!(response.http_response.status().is_success()); assert_response_body(response, r#"{"data":null}"#).await; } } From e0b1023bc893ad0355b53cbcba07880f7bcc18a7 Mon Sep 17 00:00:00 2001 From: carodewig <16093297+carodewig@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:31:03 -0400 Subject: [PATCH 3/6] chore: refactor test for simplicity --- apollo-router/src/services/http/service.rs | 13 +++++++++++++ apollo-router/src/services/http/tests.rs | 18 +++++++----------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apollo-router/src/services/http/service.rs b/apollo-router/src/services/http/service.rs index 0ee85279f6..0fcce6ba71 100644 --- a/apollo-router/src/services/http/service.rs +++ b/apollo-router/src/services/http/service.rs @@ -273,6 +273,19 @@ pub(crate) fn generate_tls_client_config( }) } +#[cfg(test)] +impl HttpClientService { + pub(crate) fn from_client_config( + client_config: crate::configuration::shared::Client, + ) -> Result { + // No TLS config - provide empty root store + let tls_root_store = RootCertStore::empty(); + let tls_client_config = generate_tls_client_config(tls_root_store, None)?; + + HttpClientService::new("test".to_string(), tls_client_config, client_config) + } +} + impl tower::Service for HttpClientService { type Response = HttpResponse; type Error = BoxError; diff --git a/apollo-router/src/services/http/tests.rs b/apollo-router/src/services/http/tests.rs index 674a027631..3db08246c7 100644 --- a/apollo-router/src/services/http/tests.rs +++ b/apollo-router/src/services/http/tests.rs @@ -568,6 +568,7 @@ mod tls { mod h2c_cleartext { use super::*; + use crate::configuration::shared::Client; // Starts a local server that responds with a default GraphQL response over plain HTTP. async fn emulate_h2c_server(listener: TcpListener) { @@ -601,17 +602,11 @@ mod h2c_cleartext { let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_h2c_server(listener)); - let subgraph_service = HttpClientService::new( - "test", - rustls::ClientConfig::builder() - .with_native_roots() - .expect("read native TLS root certificates") - .with_no_client_auth(), - crate::configuration::shared::Client::builder() - .experimental_http2(Http2Config::Http2Only) - .build(), - ) - .expect("can create a HttpService"); + let client_config = Client::builder() + .experimental_http2(Http2Config::Http2Only) + .build(); + let subgraph_service = + HttpClientService::from_client_config(client_config).expect("can create a HttpService"); let url = Uri::from_str(&format!("http://{socket_addr}")).unwrap(); let response = send_request( @@ -620,6 +615,7 @@ mod h2c_cleartext { r#"{"query":"{ me { name username } }"#, ) .await; + assert!(response.http_response.status().is_success()); assert_response_body(response, r#"{"data":null}"#).await; } From 8d3ebd242eeb280b2f62084d7205a4c19d305078 Mon Sep 17 00:00:00 2001 From: carodewig <16093297+carodewig@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:44:44 -0400 Subject: [PATCH 4/6] test: codify that http2config::enable without tls uses http1 --- apollo-router/src/services/http/tests.rs | 55 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/apollo-router/src/services/http/tests.rs b/apollo-router/src/services/http/tests.rs index 3db08246c7..bc898fc4cd 100644 --- a/apollo-router/src/services/http/tests.rs +++ b/apollo-router/src/services/http/tests.rs @@ -576,18 +576,20 @@ mod h2c_cleartext { let response_builder = http::Response::builder().header(CONTENT_TYPE, APPLICATION_JSON.essence_str()); - let response = if request.version() == Version::HTTP_2 { - let response_body = serde_json::to_string(&Response { - data: Some(Value::default()), - ..Response::default() - }); - response_builder - .status(StatusCode::OK) - .body(response_body.unwrap().into()) - } else { - response_builder + let response = match request.version() { + Version::HTTP_2 => { + let response_body = serde_json::to_string(&Response { + data: Some(Value::default()), + ..Response::default() + }); + response_builder + .status(StatusCode::OK) + .body(response_body.unwrap().into()) + } + Version::HTTP_11 => response_builder .status(StatusCode::HTTP_VERSION_NOT_SUPPORTED) - .body(Body::empty()) + .body(Body::empty()), + version => panic!("{}", format!("unexpected version {version:?}")), }; Ok(response.unwrap()) @@ -597,7 +599,7 @@ mod h2c_cleartext { } #[tokio::test(flavor = "multi_thread")] - async fn test_subgraph_h2c() { + async fn test_subgraph_h2c_works_with_http2only() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_h2c_server(listener)); @@ -619,6 +621,35 @@ mod h2c_cleartext { assert!(response.http_response.status().is_success()); assert_response_body(response, r#"{"data":null}"#).await; } + + #[tokio::test(flavor = "multi_thread")] + async fn test_subgraph_h2c_fails_with_enable() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let socket_addr = listener.local_addr().unwrap(); + tokio::task::spawn(emulate_h2c_server(listener)); + + let client_config = Client::builder() + .experimental_http2(Http2Config::Enable) + .build(); + let subgraph_service = + HttpClientService::from_client_config(client_config).expect("can create a HttpService"); + + let url = Uri::from_str(&format!("http://{socket_addr}")).unwrap(); + let response = send_request( + subgraph_service, + url, + r#"{"query":"{ me { name username } }"#, + ) + .await; + + // h2c only works with `Http2Config::Http2Only` - hyper only supports HTTP/2 with TLS or + // with 'prior knowledge' + // https://github.com/hyperium/hyper/issues/2411 + assert_eq!( + response.http_response.status(), + StatusCode::HTTP_VERSION_NOT_SUPPORTED + ); + } } mod compressed_req_res { From 2296bfc4469a862454e7c509d5cbaebfca8e7a2e Mon Sep 17 00:00:00 2001 From: carodewig <16093297+carodewig@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:51:39 -0400 Subject: [PATCH 5/6] chore: test cleanup --- apollo-router/src/services/http/tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apollo-router/src/services/http/tests.rs b/apollo-router/src/services/http/tests.rs index bc898fc4cd..f34856500d 100644 --- a/apollo-router/src/services/http/tests.rs +++ b/apollo-router/src/services/http/tests.rs @@ -589,7 +589,7 @@ mod h2c_cleartext { Version::HTTP_11 => response_builder .status(StatusCode::HTTP_VERSION_NOT_SUPPORTED) .body(Body::empty()), - version => panic!("{}", format!("unexpected version {version:?}")), + version => panic!("unexpected version {version:?}"), }; Ok(response.unwrap()) @@ -618,12 +618,11 @@ mod h2c_cleartext { ) .await; - assert!(response.http_response.status().is_success()); assert_response_body(response, r#"{"data":null}"#).await; } #[tokio::test(flavor = "multi_thread")] - async fn test_subgraph_h2c_fails_with_enable() { + async fn test_subgraph_h2c_not_used_with_enable() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_h2c_server(listener)); From eaa0db55a8e98e57468eafce996caaf88eb58b08 Mon Sep 17 00:00:00 2001 From: carodewig <16093297+carodewig@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:55:09 -0400 Subject: [PATCH 6/6] doc: add changeset --- .changesets/fix_caroline_rh_1314.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changesets/fix_caroline_rh_1314.md diff --git a/.changesets/fix_caroline_rh_1314.md b/.changesets/fix_caroline_rh_1314.md new file mode 100644 index 0000000000..7f070c2dbf --- /dev/null +++ b/.changesets/fix_caroline_rh_1314.md @@ -0,0 +1,12 @@ +### Fix `http2only` not using h2c for cleartext connections ([PR #9018](https://github.com/apollographql/router/pull/9018)) + +`hyper` does not support [upgrading cleartext connections from HTTP/1.1 to HTTP/2](https://github.com/hyperium/hyper/issues/2411). To use HTTP/2 without TLS, clients must use 'prior knowledge' — connecting with the HTTP/2 preface directly. This is what `experimental_http2: http2only` is for, but previously HTTP/1 was always enabled in the connector, causing the client to fall back to HTTP/1.1 regardless. This fix applies to all outbound HTTP connections: subgraphs, connectors, and coprocessors. + +| `experimental_http2` | TLS | protocol used | +|----------------------|-----|-----------------------------------------------| +| `disable` | yes | HTTP/1.1 | +| `disable` | no | HTTP/1.1 | +| `enable` | yes | HTTP/2 (if server supports it), else HTTP/1.1 | +| `enable` | no | HTTP/1.1 | +| `http2only` | yes | HTTP/2 | +| `http2only` | no | HTTP/2 (h2c — cleartext prior knowledge) |