Implement #8057 103 Early Hint (#8058)

Co-authored-by: Ludovic Orban <lorban@bitronix.be>
Co-authored-by: Simone Bordet <simone.bordet@gmail.com>
This commit is contained in:
Greg Wilkins 2022-06-01 11:56:54 +10:00 committed by GitHub
parent 263274891a
commit 7a1c165677
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 320 additions and 43 deletions

View File

@ -291,6 +291,7 @@ public class HttpExchange implements CyclicTimeouts.Expirable
{
responseState = State.PENDING;
responseFailure = null;
response.clearHeaders();
}
}

View File

@ -411,9 +411,8 @@ public abstract class HttpReceiver
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

View File

@ -87,6 +87,11 @@ public class HttpResponse implements Response
return headers.asImmutable();
}
public void clearHeaders()
{
headers.clear();
}
public HttpResponse addHeader(HttpField header)
{
headers.add(header);

View File

@ -187,6 +187,7 @@ public class HttpReceiverOverHTTP extends HttpReceiver implements HttpParser.Res
}
else if (read == 0)
{
assert networkBuffer.isEmpty();
releaseNetworkBuffer();
fillInterested();
return;
@ -245,18 +246,21 @@ public class HttpReceiverOverHTTP extends HttpReceiver implements HttpParser.Res
this.method = null;
if (getHttpChannel().isTunnel(method, status))
return true;
if (networkBuffer.isEmpty())
return false;
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;
if (complete)
{
if (LOG.isDebugEnabled())
LOG.debug("Discarding unexpected content after response: {}", networkBuffer);
networkBuffer.clear();
return false;
}
}
}
@ -372,7 +376,7 @@ public class HttpReceiverOverHTTP extends HttpReceiver implements HttpParser.Res
}
int status = exchange.getResponse().getStatus();
if (status != HttpStatus.CONTINUE_100)
if (!HttpStatus.isInterim(status))
{
inMessages.increment();
complete = true;

View File

@ -385,15 +385,21 @@ public class HttpGenerator
// 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)

View File

@ -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"),

View File

@ -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 class HttpStatus
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 class HttpStatus
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
* <code>Success</code> message category as defined in the <a

View File

@ -120,8 +120,7 @@ public class HttpReceiverOverHTTP2 extends HttpReceiver implements HTTP2Channel.
if (responseHeaders(exchange))
{
int status = response.getStatus();
boolean informational = HttpStatus.isInformational(status) && status != HttpStatus.SWITCHING_PROTOCOLS_101;
if (frame.isEndStream() || informational)
if (frame.isEndStream() || HttpStatus.isInterim(status))
responseSuccess(exchange);
}
else

View File

@ -98,8 +98,7 @@ public class HttpTransportOverHTTP2 implements HttpTransport
boolean isHeadRequest = HttpMethod.HEAD.is(request.getMethod());
boolean hasContent = BufferUtil.hasContent(content) && !isHeadRequest;
int status = response.getStatus();
boolean interimResponse = status == HttpStatus.CONTINUE_100 || status == HttpStatus.PROCESSING_102;
if (interimResponse)
if (HttpStatus.isInterim(status))
{
// Must not commit interim responses.
if (hasContent)

View File

@ -52,9 +52,11 @@ public class HTTP3StreamClient extends HTTP3Stream implements Stream.Client
MetaData.Response response = (MetaData.Response)frame.getMetaData();
boolean valid;
if (response.getStatus() == HttpStatus.CONTINUE_100)
valid = validateAndUpdate(EnumSet.of(FrameState.INITIAL), FrameState.CONTINUE);
valid = validateAndUpdate(EnumSet.of(FrameState.INITIAL), FrameState.INFORMATIONAL);
else if (response.getStatus() == HttpStatus.EARLY_HINT_103)
valid = validateAndUpdate(EnumSet.of(FrameState.INITIAL, FrameState.HEADER, FrameState.INFORMATIONAL), FrameState.INFORMATIONAL);
else
valid = validateAndUpdate(EnumSet.of(FrameState.INITIAL, FrameState.CONTINUE), FrameState.HEADER);
valid = validateAndUpdate(EnumSet.of(FrameState.INITIAL, FrameState.INFORMATIONAL), FrameState.HEADER);
if (valid)
{
notIdle();

View File

@ -315,7 +315,7 @@ public abstract class HTTP3Stream implements Stream, CyclicTimeouts.Expirable, A
protected enum FrameState
{
INITIAL, CONTINUE, HEADER, DATA, TRAILER, FAILED
INITIAL, INFORMATIONAL, HEADER, DATA, TRAILER, FAILED
}
private enum CloseState

View File

@ -486,7 +486,12 @@ public abstract class HTTP3StreamConnection extends AbstractConnection
else if (metaData.isResponse())
{
MetaData.Response response = (MetaData.Response)metaData;
if (response.getStatus() != HttpStatus.CONTINUE_100)
if (HttpStatus.isInformational(response.getStatus()))
{
if (LOG.isDebugEnabled())
LOG.debug("staying in parserDataMode=false for response {} on {}", metaData, this);
}
else
{
// Expect DATA frames now.
parserDataMode = true;
@ -494,11 +499,6 @@ public abstract class HTTP3StreamConnection extends AbstractConnection
if (LOG.isDebugEnabled())
LOG.debug("switching to parserDataMode=true for response {} on {}", metaData, this);
}
else
{
if (LOG.isDebugEnabled())
LOG.debug("staying in parserDataMode=false for response {} on {}", metaData, this);
}
}
else
{

View File

@ -89,8 +89,7 @@ public class HttpReceiverOverHTTP3 extends HttpReceiver implements Stream.Client
if (responseHeaders(exchange))
{
int status = response.getStatus();
boolean informational = HttpStatus.isInformational(status) && status != HttpStatus.SWITCHING_PROTOCOLS_101;
if (frame.isLast() || informational)
if (frame.isLast() || HttpStatus.isInterim(status))
responseSuccess(exchange);
else
stream.demand();

View File

@ -71,8 +71,7 @@ public class HttpTransportOverHTTP3 implements HttpTransport
boolean isHeadRequest = HttpMethod.HEAD.is(request.getMethod());
boolean hasContent = BufferUtil.hasContent(content) && !isHeadRequest;
int status = response.getStatus();
boolean interimResponse = status == HttpStatus.CONTINUE_100 || status == HttpStatus.PROCESSING_102;
if (interimResponse)
if (HttpStatus.isInterim(status))
{
// Must not commit interim responses.
if (hasContent)

View File

@ -1044,10 +1044,10 @@ public abstract class HttpChannel implements Runnable, HttpOutput.Interceptor
_combinedListener.onResponseBegin(_request);
_request.onResponseCommit();
// wrap callback to process 100 responses
// wrap callback to process informational responses
final int status = response.getStatus();
final Callback committed = (status < HttpStatus.OK_200 && status >= 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 abstract class HttpChannel implements Runnable, HttpOutput.Interceptor
}
}
private class Send100Callback extends SendCallback
private class Send1XXCallback extends SendCallback
{
private Send100Callback(Callback callback)
private Send1XXCallback(Callback callback)
{
super(callback, null, false, false);
}

View File

@ -470,6 +470,7 @@ public class Response implements HttpServletResponse
* <p>In addition to the servlet standard handling, this method supports some additional codes:</p>
* <dl>
* <dt>102</dt><dd>Send a partial PROCESSING response and allow additional responses</dd>
* <dt>103</dt><dd>Send a partial EARLY_HINT response as per <a href="https://datatracker.ietf.org/doc/html/rfc8297">RFC8297</a></dd>
* <dt>-1</dt><dd>Abort the HttpChannel and close the connection/stream</dd>
* </dl>
* @param code The error code
@ -490,6 +491,9 @@ public class Response implements HttpServletResponse
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 class Response implements HttpServletResponse
/**
* 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 class Response implements HttpServletResponse
}
}
/**
* Sends a 103 Early Hint response.
*
* Send a 103 response as per <a href="https://datatracker.ietf.org/doc/html/rfc8297">RFC8297</a>
* 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.
*

View File

@ -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<TransportScenario>
{
@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> 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<String> 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> 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"));
}
}