diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/HttpExchange.java b/jetty-client/src/main/java/org/eclipse/jetty/client/HttpExchange.java index b0ac2e1679b4..859b92c70159 100644 --- a/jetty-client/src/main/java/org/eclipse/jetty/client/HttpExchange.java +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/HttpExchange.java @@ -291,6 +291,7 @@ public void resetResponse() { responseState = State.PENDING; responseFailure = null; + response.clearHeaders(); } } diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/HttpReceiver.java b/jetty-client/src/main/java/org/eclipse/jetty/client/HttpReceiver.java index 01e662b106aa..8315e6b48cd3 100644 --- a/jetty-client/src/main/java/org/eclipse/jetty/client/HttpReceiver.java +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/HttpReceiver.java @@ -411,9 +411,8 @@ protected boolean responseSuccess(HttpExchange exchange) ResponseNotifier notifier = getHttpDestination().getResponseNotifier(); notifier.notifySuccess(listeners, response); - // Special case for 100 Continue that cannot - // be handled by the ContinueProtocolHandler. - if (exchange.getResponse().getStatus() == HttpStatus.CONTINUE_100) + // Interim responses do not terminate the exchange. + if (HttpStatus.isInterim(exchange.getResponse().getStatus())) return true; // Mark atomically the response as terminated, with diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/HttpResponse.java b/jetty-client/src/main/java/org/eclipse/jetty/client/HttpResponse.java index 3f01a0aa3752..a8576679390e 100644 --- a/jetty-client/src/main/java/org/eclipse/jetty/client/HttpResponse.java +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/HttpResponse.java @@ -87,6 +87,11 @@ public HttpFields getHeaders() return headers.asImmutable(); } + public void clearHeaders() + { + headers.clear(); + } + public HttpResponse addHeader(HttpField header) { headers.add(header); diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/http/HttpReceiverOverHTTP.java b/jetty-client/src/main/java/org/eclipse/jetty/client/http/HttpReceiverOverHTTP.java index e9d1b6aaeb33..b54115ea365d 100644 --- a/jetty-client/src/main/java/org/eclipse/jetty/client/http/HttpReceiverOverHTTP.java +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/http/HttpReceiverOverHTTP.java @@ -187,6 +187,7 @@ private void process() } else if (read == 0) { + assert networkBuffer.isEmpty(); releaseNetworkBuffer(); fillInterested(); return; @@ -245,18 +246,21 @@ private boolean parse() this.method = null; if (getHttpChannel().isTunnel(method, status)) return true; - } - if (networkBuffer.isEmpty()) - return false; + if (networkBuffer.isEmpty()) + return false; - if (complete) - { - if (LOG.isDebugEnabled()) - LOG.debug("Discarding unexpected content after response: {}", networkBuffer); - networkBuffer.clear(); + if (!HttpStatus.isInformational(status)) + { + if (LOG.isDebugEnabled()) + LOG.debug("Discarding unexpected content after response {}: {}", status, networkBuffer); + networkBuffer.clear(); + } return false; } + + if (networkBuffer.isEmpty()) + return false; } } @@ -372,7 +376,7 @@ public boolean messageComplete() } int status = exchange.getResponse().getStatus(); - if (status != HttpStatus.CONTINUE_100) + if (!HttpStatus.isInterim(status)) { inMessages.increment(); complete = true; diff --git a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpGenerator.java b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpGenerator.java index 8a4b3ebab798..16f608357f17 100644 --- a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpGenerator.java +++ b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpGenerator.java @@ -385,15 +385,21 @@ public Result generateResponse(MetaData.Response info, boolean head, ByteBuffer // Handle 1xx and no content responses int status = info.getStatus(); - if (status >= 100 && status < 200) + if (HttpStatus.isInformational(status)) { _noContentResponse = true; - - if (status != HttpStatus.SWITCHING_PROTOCOLS_101) + switch (status) { - header.put(HttpTokens.CRLF); - _state = State.COMPLETING_1XX; - return Result.FLUSH; + case HttpStatus.SWITCHING_PROTOCOLS_101: + break; + case HttpStatus.EARLY_HINT_103: + generateHeaders(header, content, last); + _state = State.COMPLETING_1XX; + return Result.FLUSH; + default: + header.put(HttpTokens.CRLF); + _state = State.COMPLETING_1XX; + return Result.FLUSH; } } else if (status == HttpStatus.NO_CONTENT_204 || status == HttpStatus.NOT_MODIFIED_304) diff --git a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java index 555fee7335ad..bc56e005aaef 100644 --- a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java +++ b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpHeader.java @@ -88,6 +88,7 @@ public enum HttpHeader AGE("Age"), ALT_SVC("Alt-Svc"), ETAG("ETag"), + LINK("Link"), LOCATION("Location"), PROXY_AUTHENTICATE("Proxy-Authenticate"), RETRY_AFTER("Retry-After"), diff --git a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpStatus.java b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpStatus.java index 5c807e4128bb..dbfdb016fcb9 100644 --- a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpStatus.java +++ b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpStatus.java @@ -25,6 +25,7 @@ public class HttpStatus public static final int CONTINUE_100 = 100; public static final int SWITCHING_PROTOCOLS_101 = 101; public static final int PROCESSING_102 = 102; + public static final int EARLY_HINT_103 = 103; public static final int OK_200 = 200; public static final int CREATED_201 = 201; @@ -103,6 +104,7 @@ public enum Code CONTINUE(CONTINUE_100, "Continue"), SWITCHING_PROTOCOLS(SWITCHING_PROTOCOLS_101, "Switching Protocols"), PROCESSING(PROCESSING_102, "Processing"), + EARLY_HINT(EARLY_HINT_103, "Early Hint"), OK(OK_200, "OK"), CREATED(CREATED_201, "Created"), @@ -339,6 +341,17 @@ public static boolean isInformational(int code) return ((100 <= code) && (code <= 199)); } + /** + * Tests whether the status code is informational but not {@code 101 Switching Protocols}. + * + * @param code the code to test + * @return whether the status code is informational but not {@code 101 Switching Protocols} + */ + public static boolean isInterim(int code) + { + return isInformational(code) && code != HttpStatus.SWITCHING_PROTOCOLS_101; + } + /** * Simple test against an code to determine if it falls into the * Success message category as defined in the = HttpStatus.CONTINUE_100) - ? new Send100Callback(callback) + final Callback committed = HttpStatus.isInformational(status) + ? new Send1XXCallback(callback) : new SendCallback(callback, content, true, complete); // committing write @@ -1477,9 +1477,9 @@ public void failed(Throwable th) } } - private class Send100Callback extends SendCallback + private class Send1XXCallback extends SendCallback { - private Send100Callback(Callback callback) + private Send1XXCallback(Callback callback) { super(callback, null, false, false); } diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java b/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java index 62eb329b0c85..7d327cbfb92e 100644 --- a/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java +++ b/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java @@ -470,6 +470,7 @@ public void sendError(int sc) throws IOException *

In addition to the servlet standard handling, this method supports some additional codes:

*
*
102
Send a partial PROCESSING response and allow additional responses
+ *
103
Send a partial EARLY_HINT response as per RFC8297
*
-1
Abort the HttpChannel and close the connection/stream
*
* @param code The error code @@ -490,6 +491,9 @@ public void sendError(int code, String message) throws IOException case HttpStatus.PROCESSING_102: sendProcessing(); break; + case HttpStatus.EARLY_HINT_103: + sendEarlyHint(); + break; default: _channel.getState().sendError(code, message); break; @@ -498,9 +502,8 @@ public void sendError(int code, String message) throws IOException /** * Sends a 102-Processing response. - * If the connection is an HTTP connection, the version is 1.1 and the - * request has a Expect header starting with 102, then a 102 response is - * sent. This indicates that the request still be processed and real response + * If the request had an Expect header starting with 102, then + * a 102 response is sent. This indicates that the request still be processed and real response * can still be sent. This method is called by sendError if it is passed 102. * * @throws IOException if unable to send the 102 response @@ -514,6 +517,22 @@ public void sendProcessing() throws IOException } } + /** + * Sends a 103 Early Hint response. + * + * Send a 103 response as per RFC8297 + * This method is called by sendError if it is passed 103. + * + * @throws IOException if unable to send the 103 response + * @see javax.servlet.http.HttpServletResponse#sendError(int) + */ + public void sendEarlyHint() throws IOException + { + if (!isCommitted()) + _channel.sendResponse(new MetaData.Response(_channel.getRequest().getHttpVersion(), HttpStatus.EARLY_HINT_103, + _channel.getResponse()._fields.asImmutable()), null, true); + } + /** * Sends a response with one of the 300 series redirection codes. * diff --git a/tests/test-http-client-transport/src/test/java/org/eclipse/jetty/http/client/InformationalResponseTest.java b/tests/test-http-client-transport/src/test/java/org/eclipse/jetty/http/client/InformationalResponseTest.java new file mode 100644 index 000000000000..358e8e5322de --- /dev/null +++ b/tests/test-http-client-transport/src/test/java/org/eclipse/jetty/http/client/InformationalResponseTest.java @@ -0,0 +1,231 @@ +// +// ======================================================================== +// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.http.client; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.client.HttpConversation; +import org.eclipse.jetty.client.HttpExchange; +import org.eclipse.jetty.client.HttpRequest; +import org.eclipse.jetty.client.ProtocolHandler; +import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.client.api.Result; +import org.eclipse.jetty.client.util.BufferingResponseListener; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpHeaderValue; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; + +import static org.eclipse.jetty.http.client.Transport.FCGI; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class InformationalResponseTest extends AbstractTest +{ + @Override + public void init(Transport transport) throws IOException + { + // Skip FCGI for now, not much interested in its server-side behavior. + Assumptions.assumeTrue(transport != FCGI); + setScenario(new TransportScenario(transport)); + } + + @ParameterizedTest + @ArgumentsSource(TransportProvider.class) + public void test102Processing(Transport transport) throws Exception + { + init(transport); + scenario.start(new AbstractHandler() + { + @Override + public void handle(String target, Request jettyRequest, HttpServletRequest request, HttpServletResponse response) throws IOException + { + jettyRequest.setHandled(true); + response.sendError(HttpStatus.PROCESSING_102); + response.sendError(HttpStatus.PROCESSING_102); + response.setStatus(200); + response.getOutputStream().print("OK"); + } + }); + long idleTimeout = 10000; + scenario.setRequestIdleTimeout(idleTimeout); + + scenario.client.getProtocolHandlers().put(new ProtocolHandler() + { + @Override + public String getName() + { + return "Processing"; + } + + @Override + public boolean accept(org.eclipse.jetty.client.api.Request request, Response response) + { + return response.getStatus() == HttpStatus.PROCESSING_102; + } + + @Override + public Response.Listener getResponseListener() + { + return new Response.Listener() + { + @Override + public void onSuccess(Response response) + { + var request = response.getRequest(); + HttpConversation conversation = ((HttpRequest)request).getConversation(); + // Reset the conversation listeners, since we are going to receive another response code + conversation.updateResponseListeners(null); + + HttpExchange exchange = conversation.getExchanges().peekLast(); + if (exchange != null && response.getStatus() == HttpStatus.PROCESSING_102) + { + // All good, continue. + exchange.resetResponse(); + } + else + { + response.abort(new IllegalStateException("should not have accepted")); + } + } + }; + } + }); + + CountDownLatch complete = new CountDownLatch(1); + AtomicReference response = new AtomicReference<>(); + BufferingResponseListener listener = new BufferingResponseListener() + { + @Override + public void onComplete(Result result) + { + response.set(result.getResponse()); + complete.countDown(); + } + }; + scenario.client.newRequest(scenario.newURI()) + .method("GET") + .headers(headers -> headers.put(HttpHeader.EXPECT, HttpHeaderValue.PROCESSING)) + .timeout(10, TimeUnit.SECONDS) + .send(listener); + + assertTrue(complete.await(10, TimeUnit.SECONDS)); + assertThat(response.get().getStatus(), is(200)); + assertThat(listener.getContentAsString(), is("OK")); + } + + @ParameterizedTest + @ArgumentsSource(TransportProvider.class) + public void test103EarlyHint(Transport transport) throws Exception + { + init(transport); + scenario.start(new AbstractHandler() + { + @Override + public void handle(String target, Request jettyRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException + { + jettyRequest.setHandled(true); + response.setHeader("Hint", "one"); + response.sendError(HttpStatus.EARLY_HINT_103); + response.setHeader("Hint", "two"); + response.sendError(HttpStatus.EARLY_HINT_103); + response.setHeader("Hint", "three"); + response.setStatus(200); + response.getOutputStream().print("OK"); + } + }); + long idleTimeout = 10000; + scenario.setRequestIdleTimeout(idleTimeout); + + List hints = new CopyOnWriteArrayList<>(); + scenario.client.getProtocolHandlers().put(new ProtocolHandler() + { + @Override + public String getName() + { + return "EarlyHint"; + } + + @Override + public boolean accept(org.eclipse.jetty.client.api.Request request, Response response) + { + return response.getStatus() == HttpStatus.EARLY_HINT_103; + } + + @Override + public Response.Listener getResponseListener() + { + return new Response.Listener() + { + @Override + public void onSuccess(Response response) + { + var request = response.getRequest(); + HttpConversation conversation = ((HttpRequest)request).getConversation(); + // Reset the conversation listeners, since we are going to receive another response code + conversation.updateResponseListeners(null); + + HttpExchange exchange = conversation.getExchanges().peekLast(); + if (exchange != null && response.getStatus() == HttpStatus.EARLY_HINT_103) + { + // All good, continue. + hints.add(response.getHeaders().get("Hint")); + exchange.resetResponse(); + } + else + { + response.abort(new IllegalStateException("should not have accepted")); + } + } + }; + } + }); + + CountDownLatch complete = new CountDownLatch(1); + AtomicReference response = new AtomicReference<>(); + BufferingResponseListener listener = new BufferingResponseListener() + { + @Override + public void onComplete(Result result) + { + hints.add(result.getResponse().getHeaders().get("Hint")); + response.set(result.getResponse()); + complete.countDown(); + } + }; + scenario.client.newRequest(scenario.newURI()) + .method("GET") + .timeout(5, TimeUnit.SECONDS) + .send(listener); + + assertTrue(complete.await(5, TimeUnit.SECONDS)); + assertThat(response.get().getStatus(), is(200)); + assertThat(listener.getContentAsString(), is("OK")); + assertThat(hints, contains("one", "two", "three")); + } +}