From 6da19c2966b14a7f9e596fb4f38cd1df6a8aead6 Mon Sep 17 00:00:00 2001 From: Daniel Kec Date: Thu, 8 Jun 2023 13:56:50 +0200 Subject: [PATCH] 6543 HTTP/2 header continuation (#6907) * 6543 HTTP/2 Client outbound header continuation --- .../nima/http2/Http2ConnectionWriter.java | 66 +++++-- .../io/helidon/nima/http2/Http2ErrorCode.java | 9 +- .../io/helidon/nima/http2/Http2Headers.java | 21 ++ .../nima/http2/MaxFrameSizeSplitTest.java | 10 + .../nima/http2/webserver/Http2Config.java | 4 +- .../nima/http2/webserver/Http2Connection.java | 62 ++++-- .../integration/http2/client/HeadersTest.java | 115 ++++++++--- .../http2/webserver/HeadersTest.java | 182 ++++++++++++++++++ 8 files changed, 407 insertions(+), 62 deletions(-) create mode 100644 nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/HeadersTest.java diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java index 9b65f4ef925..fa8e4ec35bb 100755 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java @@ -74,19 +74,62 @@ public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlag // we must enforce parallelism of exactly 1, to make sure the dynamic table is updated // and then immediately written + int maxFrameSize = flowControl.maxFrameSize(); + return withStreamLock(() -> { int written = 0; headerBuffer.clear(); headers.write(outboundDynamicTable, responseHuffman, headerBuffer); - Http2FrameHeader frameHeader = Http2FrameHeader.create(headerBuffer.available(), - Http2FrameTypes.HEADERS, - flags, - streamId); + + // Fast path when headers fits within the SETTINGS_MAX_FRAME_SIZE + if (headerBuffer.available() <= maxFrameSize) { + Http2FrameHeader frameHeader = Http2FrameHeader.create(headerBuffer.available(), + Http2FrameTypes.HEADERS, + flags, + streamId); + written += frameHeader.length(); + written += Http2FrameHeader.LENGTH; + + noLockWrite(new Http2FrameData(frameHeader, headerBuffer)); + return written; + } + + // Split header frame to smaller continuation frames RFC 9113 ยง6.10 + BufferData[] fragments = Http2Headers.split(headerBuffer, maxFrameSize); + + // First header fragment + BufferData fragment = fragments[0]; + Http2FrameHeader frameHeader; + frameHeader = Http2FrameHeader.create(fragment.available(), + Http2FrameTypes.HEADERS, + Http2Flag.HeaderFlags.create(0), + streamId); written += frameHeader.length(); written += Http2FrameHeader.LENGTH; + noLockWrite(new Http2FrameData(frameHeader, fragment)); + + // Header continuation fragments in the middle + for (int i = 1; i < fragments.length; i++) { + fragment = fragments[i]; + frameHeader = Http2FrameHeader.create(fragment.available(), + Http2FrameTypes.CONTINUATION, + Http2Flag.ContinuationFlags.create(0), + streamId); + written += frameHeader.length(); + written += Http2FrameHeader.LENGTH; + noLockWrite(new Http2FrameData(frameHeader, fragment)); + } - noLockWrite(new Http2FrameData(frameHeader, headerBuffer)); - + // Last header continuation fragment + fragment = fragments[fragments.length - 1]; + frameHeader = Http2FrameHeader.create(fragment.available(), + Http2FrameTypes.CONTINUATION, + // Last fragment needs to indicate the end of headers + Http2Flag.ContinuationFlags.create(flags.value() | Http2Flag.END_OF_HEADERS), + streamId); + written += frameHeader.length(); + written += Http2FrameHeader.LENGTH; + noLockWrite(new Http2FrameData(frameHeader, fragment)); return written; }); } @@ -104,17 +147,8 @@ public int writeHeaders(Http2Headers headers, return withStreamLock(() -> { int bytesWritten = 0; - headerBuffer.clear(); - headers.write(outboundDynamicTable, responseHuffman, headerBuffer); - bytesWritten += headerBuffer.available(); - - Http2FrameHeader frameHeader = Http2FrameHeader.create(headerBuffer.available(), - Http2FrameTypes.HEADERS, - flags, - streamId); - bytesWritten += Http2FrameHeader.LENGTH; + bytesWritten += writeHeaders(headers, streamId, flags, flowControl); - noLockWrite(new Http2FrameData(frameHeader, headerBuffer)); writeData(dataFrame, flowControl); bytesWritten += Http2FrameHeader.LENGTH; bytesWritten += dataFrame.header().length(); diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ErrorCode.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ErrorCode.java index 17051735c3c..59888524995 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ErrorCode.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ErrorCode.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -96,7 +96,12 @@ public enum Http2ErrorCode { * The endpoint requires that HTTP/1.1 be used * instead of HTTP/2. */ - HTTP_1_1_REQUIRED(0xd); + HTTP_1_1_REQUIRED(0xd), + /** + * Request header fields are too large. + * RFC6585 + */ + REQUEST_HEADER_FIELDS_TOO_LARGE(431); private static final Map BY_CODE; diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java index e9ed7ac5513..4e057140f2f 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java @@ -440,6 +440,27 @@ public void write(DynamicTable table, Http2HuffmanEncoder huffman, BufferData gr } } + static BufferData[] split(BufferData bufferData, int size) { + int length = bufferData.available(); + if (length <= size) { + return new BufferData[]{bufferData}; + } + + int lastFragmentSize = length % size; + // Avoid creating 0 length last fragment + int allFrames = (length / size) + (lastFragmentSize != 0 ? 1 : 0); + BufferData[] result = new BufferData[allFrames]; + + for (int i = 0; i < allFrames; i++) { + boolean lastFrame = (allFrames == i + 1); + byte[] frag = new byte[lastFrame ? (lastFragmentSize != 0 ? lastFragmentSize : size) : size]; + bufferData.read(frag); + result[i] = BufferData.create(frag); + } + + return result; + } + private static Http2Headers create(ServerRequestHeaders httpHeaders, PseudoHeaders pseudoHeaders) { return new Http2Headers(httpHeaders, pseudoHeaders); } diff --git a/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java b/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java index badecf1fa0a..acc08d096b9 100644 --- a/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java +++ b/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java @@ -24,6 +24,7 @@ import io.helidon.logging.common.LogConfig; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -65,6 +66,15 @@ private static Stream splitMultiple() { ); } + @Test + void splitHeaders() { + BufferData bf = BufferData.create("This is so long text!"); + BufferData[] split = Http2Headers.split(bf, 12); + assertThat(split.length, is(2)); + assertThat(split[0].available(), is(12)); + assertThat(split[1].available(), is(9)); + } + @ParameterizedTest @MethodSource void splitMultiple(SplitTest args) { diff --git a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java index 64044f5e65f..6dcffadf3d3 100644 --- a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java +++ b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java @@ -42,11 +42,11 @@ public interface Http2Config { /** * The maximum field section size that the sender is prepared to accept in bytes. * See RFC 9113 section 6.5.2 for details. - * Default is maximal unsigned int. + * Default is 8192. * * @return maximal header list size in bytes */ - @ConfiguredOption("0xFFFFFFFFL") + @ConfiguredOption("8192") long maxHeaderListSize(); /** diff --git a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java index 43cd860ce2d..01191a830e9 100755 --- a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java +++ b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java @@ -404,11 +404,10 @@ private void readFrame() { private void doContinuation() { Http2Flag.ContinuationFlags flags = frameHeader.flags(Http2FrameTypes.CONTINUATION); - List continuationData = stream(frameHeader.streamId()).contData(); - if (continuationData.isEmpty()) { - throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received continuation without headers."); - } - continuationData.add(new Http2FrameData(frameHeader, inProgressFrame())); + + stream(frameHeader.streamId()) + .addContinuation(new Http2FrameData(frameHeader, inProgressFrame())); + if (flags.endOfHeaders()) { state = State.HEADERS; } else { @@ -548,9 +547,7 @@ private void doHeaders() { // first frame, expecting continuation if (frameHeader.type() == Http2FrameType.HEADERS && !frameHeader.flags(Http2FrameTypes.HEADERS).endOfHeaders()) { // this needs to retain the data until we receive last continuation, cannot use the same data - streamContext.contData().clear(); - streamContext.contData().add(new Http2FrameData(frameHeader, inProgressFrame().copy())); - streamContext.continuationHeader = frameHeader; + streamContext.addHeadersToBeContinued(frameHeader, inProgressFrame().copy()); this.continuationExpectedStreamId = streamId; this.state = State.READ_FRAME; return; @@ -563,14 +560,12 @@ private void doHeaders() { if (frameHeader.type() == Http2FrameType.CONTINUATION) { // end of continuations with header frames - List frames = streamContext.contData(); headers = Http2Headers.create(stream, requestDynamicTable, requestHuffman, - frames.toArray(new Http2FrameData[0])); - endOfStream = streamContext.continuationHeader.flags(Http2FrameTypes.HEADERS).endOfStream(); - frames.clear(); - streamContext.continuationHeader = null; + streamContext.contData()); + endOfStream = streamContext.contHeader().flags(Http2FrameTypes.HEADERS).endOfStream(); + streamContext.clearContinuations(); continuationExpectedStreamId = 0; } else { endOfStream = frameHeader.flags(Http2FrameTypes.HEADERS).endOfStream(); @@ -720,6 +715,7 @@ private StreamContext stream(int streamId) { } streamContext = new StreamContext(streamId, + http2Config.maxHeaderListSize(), new Http2Stream(ctx, routing, http2Config, @@ -797,13 +793,16 @@ public void run() { private static class StreamContext { private final List continuationData = new ArrayList<>(); + private final long maxHeaderListSize; private final int streamId; private final Http2Stream stream; + private long headerListSize = 0; private Http2FrameHeader continuationHeader; - StreamContext(int streamId, Http2Stream stream) { + StreamContext(int streamId, long maxHeaderListSize, Http2Stream stream) { this.streamId = streamId; + this.maxHeaderListSize = maxHeaderListSize; this.stream = stream; } @@ -811,12 +810,41 @@ public Http2Stream stream() { return stream; } - public Http2FrameHeader contHeader() { + Http2FrameData[] contData() { + return continuationData.toArray(new Http2FrameData[0]); + } + + Http2FrameHeader contHeader() { return continuationHeader; } - public List contData() { - return continuationData; + void addContinuation(Http2FrameData frameData) { + if (continuationData.isEmpty()) { + throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received continuation without headers."); + } + this.continuationData.add(frameData); + addAndValidateHeaderListSize(frameData.header().length()); + } + + void addHeadersToBeContinued(Http2FrameHeader frameHeader, BufferData bufferData) { + clearContinuations(); + continuationHeader = frameHeader; + this.continuationData.add(new Http2FrameData(frameHeader, bufferData)); + addAndValidateHeaderListSize(frameHeader.length()); + } + + private void addAndValidateHeaderListSize(int headerSizeIncrement){ + // Check MAX_HEADER_LIST_SIZE + headerListSize += headerSizeIncrement; + if (headerListSize > maxHeaderListSize){ + throw new Http2Exception(Http2ErrorCode.REQUEST_HEADER_FIELDS_TOO_LARGE, + "Request Header Fields Too Large"); + } + } + + private void clearContinuations() { + continuationData.clear(); + headerListSize = 0; } } } diff --git a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java index 1d93aa5d603..dbe7f9c63be 100644 --- a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java +++ b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java @@ -16,30 +16,36 @@ package io.helidon.nima.tests.integration.http2.client; -import java.time.Duration; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeoutException; - import io.helidon.common.http.Headers; import io.helidon.common.http.Http; import io.helidon.logging.common.LogConfig; import io.helidon.nima.http2.webclient.Http2; import io.helidon.nima.http2.webclient.Http2ClientResponse; import io.helidon.nima.webclient.WebClient; - +import io.vertx.core.MultiMap; import io.vertx.core.Vertx; +import io.vertx.core.http.Http2Settings; import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; import io.vertx.core.http.HttpServerResponse; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import static java.util.concurrent.TimeUnit.MILLISECONDS; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.is; class HeadersTest { @@ -54,24 +60,39 @@ class HeadersTest { @BeforeAll static void beforeAll() throws ExecutionException, InterruptedException, TimeoutException { LogConfig.configureRuntime(); - server = vertx.createHttpServer() + server = vertx.createHttpServer(new HttpServerOptions() + .setInitialSettings(new Http2Settings() + .setMaxHeaderListSize(Integer.MAX_VALUE) + ) + ) .requestHandler(req -> { HttpServerResponse res = req.response(); switch (req.path()) { - case "/trailer" -> { - res.putHeader("test", "before"); - res.write(DATA); - res.putTrailer("Trailer-header", "trailer-test"); - res.end(); - } - case "/cont" -> { - for (int i = 0; i < 500; i++) { - res.headers().add("test-header-" + i, DATA); + case "/trailer" -> { + res.putHeader("test", "before"); + res.write(DATA); + res.putTrailer("Trailer-header", "trailer-test"); + res.end(); } - res.write(DATA); - res.end(); - } - default -> res.setStatusCode(404).end(); + case "/cont-in" -> { + for (int i = 0; i < 500; i++) { + res.headers().add("test-header-" + i, DATA); + } + res.write(DATA); + res.end(); + } + case "/cont-out" -> { + MultiMap headers = req.headers(); + StringBuilder sb = new StringBuilder(); + for (Map.Entry header : headers) { + if (!header.getKey().startsWith("test-header-")) continue; + sb.append(header.getKey() + "=" + header.getValue() + "\n"); + } + + res.write(sb.toString()); + res.end(); + } + default -> res.setStatusCode(404).end(); } }) .listen(0) @@ -97,7 +118,7 @@ static void afterAll() { } @Test - //FIXME: trailer headers are not implemented yet + //FIXME: #6544 trailer headers are not implemented yet @Disabled void trailerHeader() { try (Http2ClientResponse res = WebClient.builder(Http2.PROTOCOL) @@ -115,12 +136,12 @@ void trailerHeader() { } @Test - void continuation() { + void continuationInbound() { try (Http2ClientResponse res = WebClient.builder(Http2.PROTOCOL) .baseUri("http://localhost:" + port + "/") .build() .method(Http.Method.GET) - .path("/cont") + .path("/cont-in") .priorKnowledge(true) .request()) { @@ -133,4 +154,48 @@ void continuation() { assertThat(res.as(String.class), is(DATA)); } } + + @Test + void continuationOutbound() { + Set expected = new HashSet<>(500); + try (Http2ClientResponse res = WebClient.builder(Http2.PROTOCOL) + .baseUri("http://localhost:" + port + "/") + .build() + .method(Http.Method.GET) + .path("/cont-out") + .priorKnowledge(true) + .headers(hv -> { + for (int i = 0; i < 500; i++) { + hv.add(Http.Header.createCached("test-header-" + i, DATA + i)); + expected.add("test-header-" + i + "=" + DATA + i); + } + return hv; + }) + .request()) { + String actual = res.as(String.class); + assertThat(List.of(actual.split("\\n")), containsInAnyOrder(expected.toArray(new String[0]))); + } + } + + @Test + void continuationOutboundPost() { + Set expected = new HashSet<>(500); + try (Http2ClientResponse res = WebClient.builder(Http2.PROTOCOL) + .baseUri("http://localhost:" + port + "/") + .build() + .method(Http.Method.POST) + .path("/cont-out") + .priorKnowledge(true) + .headers(hv -> { + for (int i = 0; i < 500; i++) { + hv.add(Http.Header.createCached("test-header-" + i, DATA + i)); + expected.add("test-header-" + i + "=" + DATA + i); + } + return hv; + }) + .submit(DATA)) { + String actual = res.as(String.class); + assertThat(List.of(actual.split("\\n")), containsInAnyOrder(expected.toArray(new String[0]))); + } + } } diff --git a/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/HeadersTest.java b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/HeadersTest.java new file mode 100644 index 00000000000..5a0a1e1ac54 --- /dev/null +++ b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/HeadersTest.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.tests.integration.http2.webserver; + +import io.helidon.nima.http2.webserver.Http2ConfigDefault; +import io.helidon.nima.http2.webserver.Http2ConnectionProvider; +import io.helidon.nima.http2.webserver.Http2Route; +import io.helidon.nima.http2.webserver.Http2UpgradeProvider; +import io.helidon.nima.testing.junit5.webserver.ServerTest; +import io.helidon.nima.testing.junit5.webserver.SetUpRoute; +import io.helidon.nima.testing.junit5.webserver.SetUpServer; +import io.helidon.nima.webserver.WebServer; +import io.helidon.nima.webserver.http.HttpRouting; +import io.helidon.nima.webserver.http1.Http1ConnectionProvider; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static io.helidon.common.http.Http.Method.GET; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +@ServerTest +public class HeadersTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final String DATA = "Helidon!!!".repeat(10); + + @SetUpServer + static void setUpServer(WebServer.Builder serverBuilder) { + serverBuilder.port(-1) + // HTTP/2 prior knowledge config + .addConnectionProvider(Http2ConnectionProvider.builder() + .http2Config(Http2ConfigDefault.builder() + .sendErrorDetails(true) + .maxHeaderListSize(128_000)) + .build()) + // HTTP/1.1 -> HTTP/2 upgrade config + .addConnectionProvider(Http1ConnectionProvider.builder() + .addUpgradeProvider(Http2UpgradeProvider.builder() + .http2Config(Http2ConfigDefault.builder() + .sendErrorDetails(true) + .maxHeaderListSize(128_000)) + .build()) + .build()); + } + + @SetUpRoute + static void router(HttpRouting.Builder router) { + router.route(Http2Route.route(GET, "/ping", (req, res) -> res.send("pong"))); + router.route(Http2Route.route(GET, "/cont-out", + (req, res) -> { + for (int i = 0; i < 500; i++) { + res.header("test-header-" + i, DATA + i); + } + res.send(); + } + )); + router.route(Http2Route.route(GET, "/cont-in", + (req, res) -> { + String joinedHeaders = req.headers() + .stream() + .filter(h -> h.name().startsWith("test-header-")) + .map(h -> h.name() + "=" + h.value()) + .collect(Collectors.joining("\n")); + res.send(joinedHeaders); + } + )); + } + + @Test + void serverOutbound(WebServer server) throws IOException, InterruptedException { + URI base = URI.create("http://localhost:" + server.port()); + HttpClient client = http2Client(base); + + Set expected = new HashSet<>(500); + for (int i = 0; i < 500; i++) { + expected.add("test-header-" + i + "=" + DATA + i); + } + + HttpResponse res = client.send(HttpRequest.newBuilder() + .timeout(TIMEOUT) + .uri(base.resolve("/cont-out")) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + + List actual = res.headers() + .map() + .entrySet() + .stream() + .filter(e -> e.getKey().startsWith("test-header-")) + .map(e -> e.getKey() + "=" + String.join("", e.getValue())) + .toList(); + + assertThat(res.statusCode(), is(200)); + assertThat(actual, Matchers.containsInAnyOrder(expected.toArray(new String[0]))); + } + + @Test + void serverInbound(WebServer server) throws IOException, InterruptedException { + URI base = URI.create("http://localhost:" + server.port()); + HttpClient client = http2Client(base); + + HttpRequest.Builder req = HttpRequest.newBuilder() + .timeout(TIMEOUT) + .GET(); + + Set expected = new HashSet<>(500); + for (int i = 0; i < 800; i++) { + req.setHeader("test-header-" + i, DATA + i); + expected.add("test-header-" + i + "=" + DATA + i); + } + + HttpResponse res = client.send(req.uri(base.resolve("/cont-in")).build(), + HttpResponse.BodyHandlers.ofString()); + + assertThat(res.statusCode(), is(200)); + assertThat(List.of(res.body().split("\n")), Matchers.containsInAnyOrder(expected.toArray(new String[0]))); + } + + @Test + void serverInboundTooLarge(WebServer server) throws IOException, InterruptedException { + URI base = URI.create("http://localhost:" + server.port()); + HttpClient client = http2Client(base); + + HttpRequest.Builder req = HttpRequest.newBuilder() + .timeout(TIMEOUT) + .GET(); + + for (int i = 0; i < 5200; i++) { + req.setHeader("test-header-" + i, DATA + i); + } + + // There is no way how to access GO_AWAY status code and additional data with JDK Http client + Assertions.assertThrows(IOException.class, + () -> client.send(req.uri(base.resolve("/cont-in")).build(), + HttpResponse.BodyHandlers.ofString())); + } + + private HttpClient http2Client(URI base) throws IOException, InterruptedException { + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(TIMEOUT) + .build(); + + HttpRequest req = HttpRequest.newBuilder() + .timeout(TIMEOUT) + .GET() + .uri(base.resolve("/ping")) + .build(); + + // Java client can't do the prior knowledge + client.send(req, HttpResponse.BodyHandlers.ofString()); + return client; + } + +}