diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java b/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java index 575ba463c5d..e28a82f71a2 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.lang.System.Logger.Level; import java.net.InetSocketAddress; +import java.net.ProtocolException; import java.net.ProxySelector; import java.net.URI; import java.net.URISyntaxException; @@ -410,10 +411,62 @@ private CompletableFuture sendRequestBody(ExchangeImpl ex) { CompletableFuture cf = ex.sendBodyAsync() .thenCompose(exIm -> exIm.getResponseAsync(parentExecutor)); cf = wrapForUpgrade(cf); + // after 101 is handled we check for other 1xx responses + cf = cf.thenCompose(this::ignore1xxResponse); cf = wrapForLog(cf); return cf; } + /** + * Checks whether the passed Response has a status code between 102 and 199 (both inclusive). + * If so, then that {@code Response} is considered intermediate informational response and is + * ignored by the client. This method then creates a new {@link CompletableFuture} which + * completes when a subsequent response is sent by the server. Such newly constructed + * {@link CompletableFuture} will not complete till a "final" response (one which doesn't have + * a response code between 102 and 199 inclusive) is sent by the server. The returned + * {@link CompletableFuture} is thus capable of handling multiple subsequent intermediate + * informational responses from the server. + *

+ * If the passed Response doesn't have a status code between 102 and 199 (both inclusive) then + * this method immediately returns back a completed {@link CompletableFuture} with the passed + * {@code Response}. + *

+ * + * @param rsp The response + * @return A {@code CompletableFuture} with the final response from the server + */ + private CompletableFuture ignore1xxResponse(final Response rsp) { + final int statusCode = rsp.statusCode(); + // we ignore any response code which is 1xx. + // For 100 (with the request configured to expect-continue) and 101, we handle it + // specifically as defined in the RFC-9110, outside of this method. + // As noted in RFC-9110, section 15.2.1, if response code is 100 and if the request wasn't + // configured with expectContinue, then we ignore the 100 response and wait for the final + // response (just like any other 1xx response). + // Any other response code between 102 and 199 (both inclusive) aren't specified in the + // "HTTP semantics" RFC-9110. The spec states that these 1xx response codes are informational + // and interim and the client can choose to ignore them and continue to wait for the + // final response (headers) + if ((statusCode >= 102 && statusCode <= 199) + || (statusCode == 100 && !request.expectContinue)) { + Log.logTrace("Ignoring (1xx informational) response code {0}", rsp.statusCode()); + if (debug.on()) { + debug.log("Ignoring (1xx informational) response code " + + rsp.statusCode()); + } + assert exchImpl != null : "Illegal state - current exchange isn't set"; + // ignore this Response and wait again for the subsequent response headers + final CompletableFuture cf = exchImpl.getResponseAsync(parentExecutor); + // we recompose the CF again into the ignore1xxResponse check/function because + // the 1xx response is allowed to be sent multiple times for a request, before + // a final response arrives + return cf.thenCompose(this::ignore1xxResponse); + } else { + // return the already completed future + return MinimalFuture.completedFuture(rsp); + } + } + CompletableFuture responseAsyncImpl0(HttpConnection connection) { Function, CompletableFuture> after407Check; bodyIgnored = null; @@ -444,7 +497,30 @@ private CompletableFuture wrapForUpgrade(CompletableFuture c if (upgrading) { return cf.thenCompose(r -> checkForUpgradeAsync(r, exchImpl)); } - return cf; + // websocket requests use "Connection: Upgrade" and "Upgrade: websocket" headers. + // however, the "upgrading" flag we maintain in this class only tracks a h2 upgrade + // that we internally triggered. So it will be false in the case of websocket upgrade, hence + // this additional check. If it's a websocket request we allow 101 responses and we don't + // require any additional checks when a response arrives. + if (request.isWebSocket()) { + return cf; + } + // not expecting an upgrade, but if the server sends a 101 response then we fail the + // request and also let the ExchangeImpl deal with it as a protocol error + return cf.thenCompose(r -> { + if (r.statusCode == 101) { + final ProtocolException protoEx = new ProtocolException("Unexpected 101 " + + "response, when not upgrading"); + assert exchImpl != null : "Illegal state - current exchange isn't set"; + try { + exchImpl.onProtocolError(protoEx); + } catch (Throwable ignore){ + // ignored + } + return MinimalFuture.failedFuture(protoEx); + } + return MinimalFuture.completedFuture(r); + }); } private CompletableFuture wrapForLog(CompletableFuture cf) { diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/ExchangeImpl.java b/src/java.net.http/share/classes/jdk/internal/net/http/ExchangeImpl.java index e8b3f0b22ca..b1884c13528 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/ExchangeImpl.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/ExchangeImpl.java @@ -199,6 +199,16 @@ abstract CompletableFuture readBodyAsync(HttpResponse.BodyHandler handler, */ abstract void cancel(IOException cause); + /** + * Invoked whenever there is a (HTTP) protocol error when dealing with the response + * from the server. The implementations of {@code ExchangeImpl} are then expected to + * take necessary action that is expected by the corresponding specifications whenever + * a protocol error happens. For example, in HTTP/1.1, such protocol error would result + * in the connection being closed. + * @param cause The cause of the protocol violation + */ + abstract void onProtocolError(IOException cause); + /** * Called when the exchange is released, so that cleanup actions may be * performed - such as deregistering callbacks. diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/Http1Exchange.java b/src/java.net.http/share/classes/jdk/internal/net/http/Http1Exchange.java index 78462aa962c..d433c39aedb 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/Http1Exchange.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/Http1Exchange.java @@ -426,6 +426,15 @@ void cancel(IOException cause) { cancelImpl(cause); } + @Override + void onProtocolError(final IOException cause) { + if (debug.on()) { + debug.log("cancelling exchange due to protocol error: %s", cause.getMessage()); + } + Log.logError("cancelling exchange due to protocol error: {0}\n", cause); + cancelImpl(cause); + } + private void cancelImpl(Throwable cause) { LinkedList> toComplete = null; int count = 0; diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java b/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java index 100b6a2bc2e..7cf7731df72 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java @@ -344,7 +344,7 @@ private Http2Connection(HttpConnection connection, sendConnectionPreface(); if (!opened) { debug.log("ensure reset frame is sent to cancel initial stream"); - initialStream.sendCancelStreamFrame(); + initialStream.sendResetStreamFrame(ResetFrame.CANCEL); } } diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java b/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java index 942502d806f..d437c950e04 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java @@ -34,6 +34,7 @@ import java.net.Authenticator; import java.net.ConnectException; import java.net.CookieHandler; +import java.net.ProtocolException; import java.net.ProxySelector; import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpTimeoutException; @@ -561,6 +562,8 @@ private void debugCompleted(String tag, long startNanos, HttpRequest req) { ConnectException ce = new ConnectException(msg); ce.initCause(throwable); throw ce; + } else if (throwable instanceof ProtocolException) { + throw new ProtocolException(msg); } else if (throwable instanceof IOException) { throw new IOException(msg, throwable); } else { diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/Stream.java b/src/java.net.http/share/classes/jdk/internal/net/http/Stream.java index 0d890944f9d..3acf54011e5 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/Stream.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/Stream.java @@ -1213,6 +1213,16 @@ void cancel(IOException cause) { cancelImpl(cause); } + @Override + void onProtocolError(final IOException cause) { + if (debug.on()) { + debug.log("cancelling exchange on stream %d due to protocol error: %s", streamid, cause.getMessage()); + } + Log.logError("cancelling exchange on stream {0} due to protocol error: {1}\n", streamid, cause); + // send a RESET frame and close the stream + cancelImpl(cause, ResetFrame.PROTOCOL_ERROR); + } + void connectionClosing(Throwable cause) { Flow.Subscriber subscriber = responseSubscriber == null ? pendingResponseSubscriber : responseSubscriber; @@ -1224,6 +1234,10 @@ void connectionClosing(Throwable cause) { // This method sends a RST_STREAM frame void cancelImpl(Throwable e) { + cancelImpl(e, ResetFrame.CANCEL); + } + + private void cancelImpl(final Throwable e, final int resetFrameErrCode) { errorRef.compareAndSet(null, e); if (debug.on()) { if (streamid == 0) debug.log("cancelling stream: %s", (Object)e); @@ -1255,14 +1269,14 @@ void cancelImpl(Throwable e) { try { // will send a RST_STREAM frame if (streamid != 0 && streamState == 0) { - e = Utils.getCompletionCause(e); - if (e instanceof EOFException) { + final Throwable cause = Utils.getCompletionCause(e); + if (cause instanceof EOFException) { // read EOF: no need to try & send reset connection.decrementStreamsCount(streamid); connection.closeStream(streamid); } else { // no use to send CANCEL if already closed. - sendCancelStreamFrame(); + sendResetStreamFrame(resetFrameErrCode); } } } catch (Throwable ex) { @@ -1270,10 +1284,10 @@ void cancelImpl(Throwable e) { } } - void sendCancelStreamFrame() { + void sendResetStreamFrame(final int resetFrameErrCode) { // do not reset a stream until it has a streamid. - if (streamid > 0 && markStream(ResetFrame.CANCEL) == 0) { - connection.resetStream(streamid, ResetFrame.CANCEL); + if (streamid > 0 && markStream(resetFrameErrCode) == 0) { + connection.resetStream(streamid, resetFrameErrCode); } close(); } diff --git a/test/jdk/java/net/httpclient/Response1xxTest.java b/test/jdk/java/net/httpclient/Response1xxTest.java new file mode 100644 index 00000000000..7988a1fecde --- /dev/null +++ b/test/jdk/java/net/httpclient/Response1xxTest.java @@ -0,0 +1,498 @@ +/* + * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ProtocolException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import javax.net.ssl.SSLContext; + +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +/** + * @test + * @bug 8292044 + * @summary Tests behaviour of HttpClient when server responds with 102 or 103 status codes + * @modules java.base/sun.net.www.http + * java.net.http/jdk.internal.net.http.common + * java.net.http/jdk.internal.net.http.frame + * java.net.http/jdk.internal.net.http.hpack + * java.logging + * jdk.httpserver + * @library /test/lib http2/server + * @build Http2TestServer HttpServerAdapters SpecialHeadersTest + * @build jdk.test.lib.net.SimpleSSLContext + * @run testng/othervm -Djdk.internal.httpclient.debug=true + * * -Djdk.httpclient.HttpClient.log=headers,requests,responses,errors Response1xxTest + */ +public class Response1xxTest implements HttpServerAdapters { + private static final String EXPECTED_RSP_BODY = "Hello World"; + + private ServerSocket serverSocket; + private Http11Server server; + private String http1RequestURIBase; + + + private HttpTestServer http2Server; // h2c + private String http2RequestURIBase; + + + private SSLContext sslContext; + private HttpTestServer https2Server; // h2 + private String https2RequestURIBase; + + private final ReferenceTracker TRACKER = ReferenceTracker.INSTANCE; + + @BeforeClass + public void setup() throws Exception { + serverSocket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()); + server = new Http11Server(serverSocket); + new Thread(server).start(); + http1RequestURIBase = URIBuilder.newBuilder().scheme("http").loopback() + .port(serverSocket.getLocalPort()).build().toString(); + + http2Server = HttpTestServer.of(new Http2TestServer("localhost", false, 0)); + http2Server.addHandler(new Http2Handler(), "/http2/102"); + http2Server.addHandler(new Http2Handler(), "/http2/103"); + http2Server.addHandler(new Http2Handler(), "/http2/100"); + http2Server.addHandler(new Http2Handler(), "/http2/101"); + http2Server.addHandler(new OKHandler(), "/http2/200"); + http2Server.addHandler(new OnlyInformationalHandler(), "/http2/only-informational"); + http2RequestURIBase = URIBuilder.newBuilder().scheme("http").loopback() + .port(http2Server.getAddress().getPort()) + .path("/http2").build().toString(); + + http2Server.start(); + System.out.println("Started HTTP2 server at " + http2Server.getAddress()); + + sslContext = new SimpleSSLContext().get(); + if (sslContext == null) { + throw new AssertionError("Unexpected null sslContext"); + } + https2Server = HttpTestServer.of(new Http2TestServer("localhost", + true, sslContext)); + https2Server.addHandler(new Http2Handler(), "/http2/101"); + https2RequestURIBase = URIBuilder.newBuilder().scheme("https").loopback() + .port(https2Server.getAddress().getPort()) + .path("/http2").build().toString(); + https2Server.start(); + System.out.println("Started (https) HTTP2 server at " + https2Server.getAddress()); + + } + + @AfterClass + public void teardown() throws Throwable { + try { + assertNoOutstandingClientOps(); + } finally { + if (server != null) { + server.stop = true; + System.out.println("(HTTP 1.1) Server stop requested"); + } + if (serverSocket != null) { + serverSocket.close(); + System.out.println("Closed (HTTP 1.1) server socket"); + } + if (http2Server != null) { + http2Server.stop(); + System.out.println("Stopped HTTP2 server"); + } + if (https2Server != null) { + https2Server.stop(); + System.out.println("Stopped (https) HTTP2 server"); + } + } + } + + private static final class Http11Server implements Runnable { + private static final int CONTENT_LENGTH = EXPECTED_RSP_BODY.getBytes(StandardCharsets.UTF_8).length; + + private static final String HTTP_1_1_RSP_200 = "HTTP/1.1 200 OK\r\n" + + "Content-Length: " + CONTENT_LENGTH + "\r\n\r\n" + + EXPECTED_RSP_BODY; + + private static final String REQ_LINE_FOO = "GET /test/foo HTTP/1.1\r\n"; + private static final String REQ_LINE_BAR = "GET /test/bar HTTP/1.1\r\n"; + private static final String REQ_LINE_HELLO = "GET /test/hello HTTP/1.1\r\n"; + private static final String REQ_LINE_BYE = "GET /test/bye HTTP/1.1\r\n"; + + + private final ServerSocket serverSocket; + private volatile boolean stop; + + private Http11Server(final ServerSocket serverSocket) { + this.serverSocket = serverSocket; + } + + @Override + public void run() { + System.out.println("Server running at " + serverSocket); + while (!stop) { + Socket socket = null; + try { + // accept a connection + socket = serverSocket.accept(); + System.out.println("Accepted connection from client " + socket); + // read request + final String requestLine; + try { + requestLine = readRequestLine(socket); + } catch (Throwable t) { + // ignore connections from potential rogue client + System.err.println("Ignoring connection/request from client " + socket + + " due to exception:"); + t.printStackTrace(); + // close the socket + safeClose(socket); + continue; + } + System.out.println("Received following request line from client " + socket + + " :\n" + requestLine); + final int informationalResponseCode; + if (requestLine.startsWith(REQ_LINE_FOO)) { + // we will send intermediate/informational 102 response + informationalResponseCode = 102; + } else if (requestLine.startsWith(REQ_LINE_BAR)) { + // we will send intermediate/informational 103 response + informationalResponseCode = 103; + } else if (requestLine.startsWith(REQ_LINE_HELLO)) { + // we will send intermediate/informational 100 response + informationalResponseCode = 100; + } else if (requestLine.startsWith(REQ_LINE_BYE)) { + // we will send intermediate/informational 101 response + informationalResponseCode = 101; + } else { + // unexpected client. ignore and close the client + System.err.println("Ignoring unexpected request from client " + socket); + safeClose(socket); + continue; + } + try (final OutputStream os = socket.getOutputStream()) { + // send informational response headers a few times (spec allows them to + // be sent multiple times) + for (int i = 0; i < 3; i++) { + // send 1xx response header + if (informationalResponseCode == 101) { + os.write(("HTTP/1.1 " + informationalResponseCode + "\r\n" + + "Connection: upgrade\r\n" + + "Upgrade: websocket\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + } else { + os.write(("HTTP/1.1 " + informationalResponseCode + "\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + } + os.flush(); + System.out.println("Sent response code " + informationalResponseCode + + " to client " + socket); + } + // now send a final response + System.out.println("Now sending 200 response code to client " + socket); + os.write(HTTP_1_1_RSP_200.getBytes(StandardCharsets.UTF_8)); + os.flush(); + System.out.println("Sent 200 response code to client " + socket); + } + } catch (Throwable t) { + // close the client connection + safeClose(socket); + // continue accepting any other client connections until we are asked to stop + System.err.println("Ignoring exception in server:"); + t.printStackTrace(); + } + } + } + + static String readRequestLine(final Socket sock) throws IOException { + final InputStream is = sock.getInputStream(); + final StringBuilder sb = new StringBuilder(""); + byte[] buf = new byte[1024]; + while (!sb.toString().endsWith("\r\n\r\n")) { + final int numRead = is.read(buf); + if (numRead == -1) { + return sb.toString(); + } + final String part = new String(buf, 0, numRead, StandardCharsets.ISO_8859_1); + sb.append(part); + } + return sb.toString(); + } + + private static void safeClose(final Socket socket) { + try { + socket.close(); + } catch (Throwable t) { + // ignore + } + } + } + + private static class Http2Handler implements HttpTestHandler { + + @Override + public void handle(final HttpTestExchange exchange) throws IOException { + final URI requestURI = exchange.getRequestURI(); + final int informationResponseCode; + if (requestURI.getPath().endsWith("/102")) { + informationResponseCode = 102; + } else if (requestURI.getPath().endsWith("/103")) { + informationResponseCode = 103; + } else if (requestURI.getPath().endsWith("/100")) { + informationResponseCode = 100; + } else if (requestURI.getPath().endsWith("/101")) { + informationResponseCode = 101; + } else { + // unexpected request + System.err.println("Unexpected request " + requestURI + " from client " + + exchange.getRemoteAddress()); + exchange.sendResponseHeaders(400, -1); + return; + } + // send informational response headers a few times (spec allows them to + // be sent multiple times) + for (int i = 0; i < 3; i++) { + exchange.sendResponseHeaders(informationResponseCode, -1); + System.out.println("Sent " + informationResponseCode + " response code from H2 server"); + } + // now send 200 response + try { + final byte[] body = EXPECTED_RSP_BODY.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + System.out.println("Sent 200 response from H2 server"); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + System.out.println("Sent response body from H2 server"); + } catch (Throwable e) { + System.err.println("Failed to send response from HTTP2 handler:"); + e.printStackTrace(); + throw e; + } + } + } + + private static class OnlyInformationalHandler implements HttpTestHandler { + + @Override + public void handle(final HttpTestExchange exchange) throws IOException { + // we only send informational response and then return + for (int i = 0; i < 5; i++) { + exchange.sendResponseHeaders(102, -1); + System.out.println("Sent 102 response code from H2 server"); + // wait for a while before sending again + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + // just return + System.err.println("Handler thread interrupted"); + } + } + } + } + + private static class OKHandler implements HttpTestHandler { + + @Override + public void handle(final HttpTestExchange exchange) throws IOException { + exchange.sendResponseHeaders(200, -1); + } + } + + /** + * Tests that when a HTTP/1.1 server sends intermediate 1xx response codes and then the final + * response, the client (internally) will ignore those intermediate informational response codes + * and only return the final response to the application + */ + @Test + public void test1xxForHTTP11() throws Exception { + final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .proxy(HttpClient.Builder.NO_PROXY).build(); + TRACKER.track(client); + final URI[] requestURIs = new URI[]{ + new URI(http1RequestURIBase + "/test/foo"), + new URI(http1RequestURIBase + "/test/bar"), + new URI(http1RequestURIBase + "/test/hello")}; + for (final URI requestURI : requestURIs) { + final HttpRequest request = HttpRequest.newBuilder(requestURI).build(); + System.out.println("Issuing request to " + requestURI); + final HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + Assert.assertEquals(response.version(), HttpClient.Version.HTTP_1_1, + "Unexpected HTTP version in response"); + Assert.assertEquals(response.statusCode(), 200, "Unexpected response code"); + Assert.assertEquals(response.body(), EXPECTED_RSP_BODY, "Unexpected response body"); + } + } + + /** + * Tests that when a HTTP2 server sends intermediate 1xx response codes and then the final + * response, the client (internally) will ignore those intermediate informational response codes + * and only return the final response to the application + */ + @Test + public void test1xxForHTTP2() throws Exception { + final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .proxy(HttpClient.Builder.NO_PROXY).build(); + TRACKER.track(client); + final URI[] requestURIs = new URI[]{ + new URI(http2RequestURIBase + "/102"), + new URI(http2RequestURIBase + "/103"), + new URI(http2RequestURIBase + "/100")}; + for (final URI requestURI : requestURIs) { + final HttpRequest request = HttpRequest.newBuilder(requestURI).build(); + System.out.println("Issuing request to " + requestURI); + final HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + Assert.assertEquals(response.version(), HttpClient.Version.HTTP_2, + "Unexpected HTTP version in response"); + Assert.assertEquals(response.statusCode(), 200, "Unexpected response code"); + Assert.assertEquals(response.body(), EXPECTED_RSP_BODY, "Unexpected response body"); + } + } + + + /** + * Tests that when a request is issued with a specific request timeout and the server + * responds with intermediate 1xx response code but doesn't respond with a final response within + * the timeout duration, then the application fails with a request timeout + */ + @Test + public void test1xxRequestTimeout() throws Exception { + final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .proxy(HttpClient.Builder.NO_PROXY).build(); + TRACKER.track(client); + final URI requestURI = new URI(http2RequestURIBase + "/only-informational"); + final Duration requestTimeout = Duration.ofSeconds(2); + final HttpRequest request = HttpRequest.newBuilder(requestURI).timeout(requestTimeout) + .build(); + System.out.println("Issuing request to " + requestURI); + // we expect the request to timeout + Assert.assertThrows(HttpTimeoutException.class, () -> { + client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + }); + } + + /** + * Tests that when the HTTP/1.1 server sends a 101 response when the request hasn't asked + * for an "Upgrade" then the request fails. + */ + @Test + public void testHTTP11Unexpected101() throws Exception { + final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .proxy(HttpClient.Builder.NO_PROXY).build(); + TRACKER.track(client); + final URI requestURI = new URI(http1RequestURIBase + "/test/bye"); + final HttpRequest request = HttpRequest.newBuilder(requestURI).build(); + System.out.println("Issuing request to " + requestURI); + // we expect the request to fail because the server sent an unexpected 101 + Assert.assertThrows(ProtocolException.class, + () -> client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))); + } + + + /** + * Tests that when the HTTP2 server (over HTTPS) sends a 101 response when the request + * hasn't asked for an "Upgrade" then the request fails. + */ + @Test + public void testSecureHTTP2Unexpected101() throws Exception { + final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .sslContext(sslContext) + .proxy(HttpClient.Builder.NO_PROXY).build(); + TRACKER.track(client); + final URI requestURI = new URI(https2RequestURIBase + "/101"); + final HttpRequest request = HttpRequest.newBuilder(requestURI).build(); + System.out.println("Issuing request to " + requestURI); + // we expect the request to fail because the server sent an unexpected 101 + // to check - we get here an IOException not a ProtocalException (compared to 17 or 21) + //Assert.assertThrows(ProtocolException.class, + Assert.assertThrows(IOException.class, + () -> client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))); + } + + /** + * Tests that when the HTTP2 server (over plain HTTP) sends a 101 response when the request + * hasn't asked for an "Upgrade" then the request fails. + */ + @Test + public void testPlainHTTP2Unexpected101() throws Exception { + final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .proxy(HttpClient.Builder.NO_PROXY).build(); + TRACKER.track(client); + // when using HTTP2 version against a "http://" (non-secure) URI + // the HTTP client (implementation) internally initiates a HTTP/1.1 connection + // and then does an "Upgrade:" to "h2c". This it does when there isn't already a + // H2 connection against the target/destination server. So here we initiate a dummy request + // using the client instance against the same target server and just expect it to return + // back successfully. Once that connection is established (and internally pooled), the client + // will then reuse that connection and won't issue an "Upgrade:" and thus we can then + // start our testing + warmupH2Client(client); + // start the actual testing + final URI requestURI = new URI(http2RequestURIBase + "/101"); + final HttpRequest request = HttpRequest.newBuilder(requestURI).build(); + System.out.println("Issuing request to " + requestURI); + // we expect the request to fail because the server sent an unexpected 101 + Assert.assertThrows(ProtocolException.class, + () -> client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))); + } + + // sends a request and expects a 200 response back + private void warmupH2Client(final HttpClient client) throws Exception { + final URI requestURI = new URI(http2RequestURIBase + "/200"); + final HttpRequest request = HttpRequest.newBuilder(requestURI).build(); + System.out.println("Issuing (warmup) request to " + requestURI); + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding()); + Assert.assertEquals(response.statusCode(), 200, "Unexpected response code"); + } + + // verifies that the HttpClient being tracked has no outstanding operations + private void assertNoOutstandingClientOps() throws AssertionError { + System.gc(); + final AssertionError refCheckFailure = TRACKER.check(1000); + if (refCheckFailure != null) { + throw refCheckFailure; + } + // successful test completion + } +}