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) |