HTTP/2 Client Library
In the vast majority of cases, client applications should use the generic, high-level, HTTP client library that also provides HTTP/2 support via the pluggable HTTP/2 transport or the dynamic transport.
The high-level HTTP library supports cookies, authentication, redirection, connection pooling and a number of other features that are absent in the low-level HTTP/2 library.
The HTTP/2 client library has been designed for those applications that need low-level access to HTTP/2 features such as sessions, streams and frames, and this is quite a rare use case.
See also the correspondent HTTP/2 server library.
Introducing HTTP2Client
The Maven artifact coordinates for the HTTP/2 client library are the following:
<dependency>
<groupId>org.eclipse.jetty.http2</groupId>
<artifactId>jetty-http2-client</artifactId>
<version>12.0.16-SNAPSHOT</version>
</dependency>
The main class is named org.eclipse.jetty.http2.client.HTTP2Client
, and must be created, configured and started before use:
// Instantiate HTTP2Client.
HTTP2Client http2Client = new HTTP2Client();
// Configure HTTP2Client, for example:
http2Client.setStreamIdleTimeout(15000);
// Start HTTP2Client.
http2Client.start();
When your application stops, or otherwise does not need HTTP2Client
anymore, it should stop the HTTP2Client
instance (or instances) that were started:
// Stop HTTP2Client.
http2Client.stop();
HTTP2Client
allows client applications to connect to an HTTP/2 server.
A session represents a single TCP connection to an HTTP/2 server and is defined by class org.eclipse.jetty.http2.api.Session
.
A session typically has a long life — once the TCP connection is established, it remains open until it is not used anymore (and therefore it is closed by the idle timeout mechanism), until a fatal error occurs (for example, a network failure), or if one of the peers decides unilaterally to close the TCP connection.
HTTP/2 is a multiplexed protocol: it allows multiple HTTP/2 requests to be sent on the same TCP connection, or session. Each request/response cycle is represented by a stream. Therefore, a single session manages multiple concurrent streams. A stream has typically a very short life compared to the session: a stream only exists for the duration of the request/response cycle and then disappears.
HTTP/2 Flow Control
The HTTP/2 protocol is flow controlled (see the specification). This means that a sender and a receiver maintain a flow control window that tracks the number of data bytes sent and received, respectively. When a sender sends data bytes, it reduces its flow control window. When a receiver receives data bytes, it also reduces its flow control window, and then passes the received data bytes to the application. The application consumes the data bytes and tells back the receiver that it has consumed the data bytes. The receiver then enlarges the flow control window, and the implementation arranges to send a message to the sender with the number of bytes consumed, so that the sender can enlarge its flow control window.
A sender can send data bytes up to its whole flow control window, then it must stop sending. The sender may resume sending data bytes when it receives a message from the receiver that the data bytes sent previously have been consumed. This message enlarges the sender flow control window, which allows the sender to send more data bytes.
HTTP/2 defines two flow control windows: one for each session, and one for each stream. Let’s see with an example how they interact, assuming that in this example the session flow control window is 120 bytes and the stream flow control window is 100 bytes.
The sender opens a session, and then opens stream_1
on that session, and sends 80
data bytes.
At this point the session flow control window is 40
bytes (120 - 80
), and stream_1
's flow control window is 20
bytes (100 - 80
).
The sender now opens stream_2
on the same session and sends 40
data bytes.
At this point, the session flow control window is 0
bytes (40 - 40
), while stream_2
's flow control window is 60
(100 - 40
).
Since now the session flow control window is 0
, the sender cannot send more data bytes, neither on stream_1
nor on stream_2
, nor on other streams, despite all the streams having their stream flow control windows greater than 0
.
The receiver consumes stream_2
's 40
data bytes and sends a message to the sender with this information.
At this point, the session flow control window is 40
(0 + 40
), stream_1
's flow control window is still 20
and stream_2
's flow control window is 100
(60 + 40
).
If the sender opens stream_3
and would like to send 50
data bytes, it would only be able to send 40
because that is the maximum allowed by the session flow control window at this point.
It is therefore very important that applications notify the fact that they have consumed data bytes as soon as possible, so that the implementation (the receiver) can send a message to the sender (in the form of a WINDOW_UPDATE
frame) with the information to enlarge the flow control window, therefore reducing the possibility that sender stalls due to the flow control windows being reduced to 0
.
How a client application should handle HTTP/2 flow control is discussed in details in this section.
Connecting to the Server
The first thing an application should do is to connect to the server and obtain a Session
.
The following example connects to the server on a clear-text port:
// Address of the server's clear-text port.
SocketAddress serverAddress = new InetSocketAddress("localhost", 8080);
// Connect to the server, the CompletableFuture will be
// notified when the connection is succeeded (or failed).
CompletableFuture<Session> sessionCF = http2Client.connect(serverAddress, new Session.Listener() {});
// Block to obtain the Session.
// Alternatively you can use the CompletableFuture APIs to avoid blocking.
Session session = sessionCF.get();
The following example connects to the server on an encrypted port:
HTTP2Client http2Client = new HTTP2Client();
http2Client.start();
ClientConnector connector = http2Client.getClientConnector();
// Address of the server's encrypted port.
SocketAddress serverAddress = new InetSocketAddress("localhost", 8443);
// Connect to the server, the CompletableFuture will be
// notified when the connection is succeeded (or failed).
CompletableFuture<Session> sessionCF = http2Client.connect(connector.getSslContextFactory(), serverAddress, new Session.Listener() {});
// Block to obtain the Session.
// Alternatively you can use the CompletableFuture APIs to avoid blocking.
Session session = sessionCF.get();
Applications must know in advance whether they want to connect to a clear-text or encrypted port, and pass the SslContextFactory parameter accordingly to the connect(...) method.
|
Configuring the Session
The connect(...)
method takes a Session.Listener
parameter.
This listener’s onPreface(...)
method is invoked just before establishing the connection to the server to gather the client configuration to send to the server.
Client applications can override this method to change the default configuration:
SocketAddress serverAddress = new InetSocketAddress("localhost", 8080);
http2Client.connect(serverAddress, new Session.Listener()
{
@Override
public Map<Integer, Integer> onPreface(Session session)
{
Map<Integer, Integer> configuration = new HashMap<>();
// Disable push from the server.
configuration.put(SettingsFrame.ENABLE_PUSH, 0);
// Override HTTP2Client.initialStreamRecvWindow for this session.
configuration.put(SettingsFrame.INITIAL_WINDOW_SIZE, 1024 * 1024);
return configuration;
}
});
The Session.Listener
is notified of session events originated by the server such as receiving a SETTINGS
frame from the server, or the server closing the connection, or the client timing out the connection due to idleness.
Please refer to the Session.Listener
javadocs for the complete list of events.
Once a Session
has been established, the communication with the server happens by exchanging frames, as specified in the HTTP/2 specification.
Sending a Request
Sending an HTTP request to the server, and receiving a response, creates a stream that encapsulates the exchange of HTTP/2 frames that compose the request and the response.
In order to send an HTTP request to the server, the client must send a HEADERS
frame.
HEADERS
frames carry the request method, the request URI and the request headers.
Sending the HEADERS
frame opens the Stream
:
SocketAddress serverAddress = new InetSocketAddress("localhost", 8080);
CompletableFuture<Session> sessionCF = http2Client.connect(serverAddress, new Session.Listener() {});
Session session = sessionCF.get();
// Configure the request headers.
HttpFields requestHeaders = HttpFields.build()
.put(HttpHeader.USER_AGENT, "Jetty HTTP2Client 12.0.16-SNAPSHOT");
// The request metadata with method, URI and headers.
MetaData.Request request = new MetaData.Request("GET", HttpURI.from("http://localhost:8080/path"), HttpVersion.HTTP_2, requestHeaders);
// The HTTP/2 HEADERS frame, with endStream=true
// to signal that this request has no content.
HeadersFrame headersFrame = new HeadersFrame(request, null, true);
// Open a Stream by sending the HEADERS frame.
session.newStream(headersFrame, null);
Note how Session.newStream(...)
takes a Stream.Listener
parameter.
This listener is notified of stream events originated by the server such as receiving HEADERS
or DATA
frames that are part of the response, discussed in more details in the section below.
Please refer to the Stream.Listener
javadocs for the complete list of events.
HTTP requests may have content, which is sent using the Stream
APIs:
SocketAddress serverAddress = new InetSocketAddress("localhost", 8080);
CompletableFuture<Session> sessionCF = http2Client.connect(serverAddress, new Session.Listener() {});
Session session = sessionCF.get();
// Configure the request headers.
HttpFields requestHeaders = HttpFields.build()
.put(HttpHeader.CONTENT_TYPE, "application/json");
// The request metadata with method, URI and headers.
MetaData.Request request = new MetaData.Request("POST", HttpURI.from("http://localhost:8080/path"), HttpVersion.HTTP_2, requestHeaders);
// The HTTP/2 HEADERS frame, with endStream=false to
// signal that there will be more frames in this stream.
HeadersFrame headersFrame = new HeadersFrame(request, null, false);
// Open a Stream by sending the HEADERS frame.
CompletableFuture<Stream> streamCF = session.newStream(headersFrame, null);
// Block to obtain the Stream.
// Alternatively you can use the CompletableFuture APIs to avoid blocking.
Stream stream = streamCF.get();
// The request content, in two chunks.
String content1 = "{\"greet\": \"hello world\"}";
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode(content1);
String content2 = "{\"user\": \"jetty\"}";
ByteBuffer buffer2 = StandardCharsets.UTF_8.encode(content2);
// Send the first DATA frame on the stream, with endStream=false
// to signal that there are more frames in this stream.
CompletableFuture<Stream> dataCF1 = stream.data(new DataFrame(stream.getId(), buffer1, false));
// Only when the first chunk has been sent we can send the second,
// with endStream=true to signal that there are no more frames.
dataCF1.thenCompose(s -> s.data(new DataFrame(s.getId(), buffer2, true)));
When sending two DATA frames consecutively, the second call to Stream.data(...) must be done only when the first is completed, or a WritePendingException will be thrown.
Use the Callback APIs or CompletableFuture APIs to ensure that the second Stream.data(...) call is performed when the first completed successfully.
|
Receiving a Response
Response events are delivered to the Stream.Listener
passed to Session.newStream(...)
.
An HTTP response is typically composed of a HEADERS
frame containing the HTTP status code and the response headers, and optionally one or more DATA
frames containing the response content bytes.
The HTTP/2 protocol also supports response trailers (that is, headers that are sent after the response content) that also are sent using a HEADERS
frame.
A client application can therefore receive the HTTP/2 frames sent by the server by implementing the relevant methods in Stream.Listener
:
// Open a Stream by sending the HEADERS frame.
session.newStream(headersFrame, new Stream.Listener()
{
@Override
public void onHeaders(Stream stream, HeadersFrame frame)
{
MetaData metaData = frame.getMetaData();
// Is this HEADERS frame the response or the trailers?
if (metaData.isResponse())
{
MetaData.Response response = (MetaData.Response)metaData;
System.getLogger("http2").log(INFO, "Received response {0}", response);
if (!frame.isEndStream())
{
// Demand for DATA frames, so that onDataAvailable()
// below will be called when they are available.
stream.demand();
}
}
else
{
System.getLogger("http2").log(INFO, "Received trailers {0}", metaData.getHttpFields());
}
}
@Override
public void onDataAvailable(Stream stream)
{
// Read a Data object.
Stream.Data data = stream.readData();
if (data == null)
{
// Demand more DATA frames.
stream.demand();
return;
}
// Get the content buffer.
ByteBuffer buffer = data.frame().getByteBuffer();
// Consume the buffer, here - as an example - just log it.
System.getLogger("http2").log(INFO, "Consuming buffer {0}", buffer);
// Tell the implementation that the buffer has been consumed.
data.release();
if (!data.frame().isEndStream())
{
// Demand more DATA frames when they are available.
stream.demand();
}
}
});
When Just returning from the Applications must call |
Applications that consume the content buffer within onDataAvailable(Stream stream)
(for example, writing it to a file, or copying the bytes to another storage) should call Data.release()
as soon as they have consumed the content buffer.
This allows the implementation to reuse the buffer, reducing the memory requirements needed to handle the content buffers.
Alternatively, an application may store away the Data
object to consume the buffer bytes later, or pass the Data
object to another asynchronous API (this is typical in proxy applications).
The call to |
Applications can unwrap the Data
object into some other object that may be used later, provided that the release semantic is maintained:
record Chunk(ByteBuffer byteBuffer, Callback callback)
{
}
// A queue that consumers poll to consume content asynchronously.
Queue<Chunk> dataQueue = new ConcurrentLinkedQueue<>();
// Implementation of Stream.Listener.onDataAvailable(Stream stream)
// in case of unwrapping of the Data object for asynchronous content
// consumption and demand.
Stream.Listener listener = new Stream.Listener()
{
@Override
public void onDataAvailable(Stream stream)
{
Stream.Data data = stream.readData();
if (data == null)
{
stream.demand();
return;
}
// Get the content buffer.
ByteBuffer byteBuffer = data.frame().getByteBuffer();
// Unwrap the Data object, converting it to a Chunk.
// The Data.release() semantic is maintained in the completion of the Callback.
dataQueue.offer(new Chunk(byteBuffer, Callback.from(() ->
{
// When the buffer has been consumed, then:
// A) release the Data object.
data.release();
// B) possibly demand more DATA frames.
if (!data.frame().isEndStream())
stream.demand();
})));
// Do not demand more data here, to avoid to overflow the queue.
}
};
Applications that implement If they do not call |
Resetting a Request or Response
In HTTP/2, clients and servers have the ability to tell to the other peer that they are not interested anymore in either the request or the response, using a RST_STREAM
frame.
The HTTP2Client
APIs allow client applications to send and receive this "reset" frame:
// Open a Stream by sending the HEADERS frame.
CompletableFuture<Stream> streamCF = session.newStream(headersFrame, new Stream.Listener()
{
@Override
public void onReset(Stream stream, ResetFrame frame, Callback callback)
{
// The server reset this stream.
// Succeed the callback to signal that the reset event has been handled.
callback.succeeded();
}
});
Stream stream = streamCF.get();
// Reset this stream (for example, the user closed the application).
stream.reset(new ResetFrame(stream.getId(), ErrorCode.CANCEL_STREAM_ERROR.code), Callback.NOOP);
Receiving HTTP/2 Pushes
HTTP/2 servers have the ability to push resources related to a primary resource.
When an HTTP/2 server pushes a resource, it sends to the client a PUSH_PROMISE
frame that contains the request URI and headers that a client would use to request explicitly that resource.
Client applications can be configured to tell the server to never push resources, see this section.
Client applications can listen to the push events, and act accordingly:
// Open a Stream by sending the HEADERS frame.
CompletableFuture<Stream> streamCF = session.newStream(headersFrame, new Stream.Listener()
{
@Override
public Stream.Listener onPush(Stream pushedStream, PushPromiseFrame frame)
{
// The "request" the client would make for the pushed resource.
MetaData.Request pushedRequest = frame.getMetaData();
// The pushed "request" URI.
HttpURI pushedURI = pushedRequest.getHttpURI();
// The pushed "request" headers.
HttpFields pushedRequestHeaders = pushedRequest.getHttpFields();
// If needed, retrieve the primary stream that triggered the push.
Stream primaryStream = pushedStream.getSession().getStream(frame.getStreamId());
// Return a Stream.Listener to listen for the pushed "response" events.
return new Stream.Listener()
{
@Override
public void onHeaders(Stream stream, HeadersFrame frame)
{
// Handle the pushed stream "response".
MetaData metaData = frame.getMetaData();
if (metaData.isResponse())
{
// The pushed "response" headers.
HttpFields pushedResponseHeaders = metaData.getHttpFields();
// Typically a pushed stream has data, so demand for data.
stream.demand();
}
}
@Override
public void onDataAvailable(Stream stream)
{
// Handle the pushed stream "response" content.
Stream.Data data = stream.readData();
if (data == null)
{
stream.demand();
return;
}
// The pushed stream "response" content bytes.
ByteBuffer buffer = data.frame().getByteBuffer();
// Consume the buffer and release the Data object.
data.release();
if (!data.frame().isEndStream())
{
// Demand more DATA frames when they are available.
stream.demand();
}
}
};
}
});
If a client application does not want to handle a particular HTTP/2 push, it can just reset the pushed stream to tell the server to stop sending bytes for the pushed stream:
// Open a Stream by sending the HEADERS frame.
CompletableFuture<Stream> streamCF = session.newStream(headersFrame, new Stream.Listener()
{
@Override
public Stream.Listener onPush(Stream pushedStream, PushPromiseFrame frame)
{
// Reset the pushed stream to tell the server you are not interested.
pushedStream.reset(new ResetFrame(pushedStream.getId(), ErrorCode.CANCEL_STREAM_ERROR.code), Callback.NOOP);
// Not interested in listening to pushed response events.
return null;
}
});