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>http2-client</artifactId>
  <version>11.0.24-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 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 until it receives a message from the receiver that the data bytes have been consumed, which enlarges the 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 despite both have 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.Adapter());

// 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.Adapter());

// 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.Adapter()
{
    @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.Adapter());
Session session = sessionCF.get();

// Configure the request headers.
HttpFields requestHeaders = HttpFields.build()
    .put(HttpHeader.USER_AGENT, "Jetty HTTP2Client 11.0.24-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, new Stream.Listener.Adapter());

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.Adapter());
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, new Stream.Listener.Adapter());

// 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.Adapter()
{
    @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);
        }
        else
        {
            System.getLogger("http2").log(INFO, "Received trailers {0}", metaData.getFields());
        }
    }

    @Override
    public void onData(Stream stream, DataFrame frame, Callback callback)
    {
        // Get the content buffer.
        ByteBuffer buffer = frame.getData();

        // 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.
        callback.succeeded();

        // By returning from the method, implicitly tell the implementation
        // to deliver to this method more DATA frames when they are available.
    }
});
Returning from the onData(…​) method implicitly demands for more DATA frames (unless the one just delivered was the last). Additional DATA frames may be delivered immediately if they are available or later, asynchronously, when they arrive.

Applications that consume the content buffer within onData(…​) (for example, writing it to a file, or copying the bytes to another storage) should succeed the callback 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, a client application may store away both the buffer and the callback to consume the buffer bytes later, or pass both the buffer and the callback to another asynchronous API (this is typical in proxy applications).

Completing the Callback is very important not only to allow the implementation to reuse the buffer, but also tells the implementation to enlarge the stream and session flow control windows so that the sender will be able to send more DATA frames without stalling.

Applications can also precisely control when to demand more DATA frames, by implementing the onDataDemanded(…​) method instead of onData(…​):

class Chunk
{
    private final ByteBuffer buffer;
    private final Callback callback;

    Chunk(ByteBuffer buffer, Callback callback)
    {
        this.buffer = buffer;
        this.callback = callback;
    }
}

// A queue that consumers poll to consume content asynchronously.
Queue<Chunk> dataQueue = new ConcurrentLinkedQueue<>();

// Implementation of Stream.Listener.onDataDemanded(...)
// in case of asynchronous content consumption and demand.
Stream.Listener listener = new Stream.Listener.Adapter()
{
    @Override
    public void onDataDemanded(Stream stream, DataFrame frame, Callback callback)
    {
        // Get the content buffer.
        ByteBuffer buffer = frame.getData();

        // Store buffer to consume it asynchronously, and wrap the callback.
        dataQueue.offer(new Chunk(buffer, Callback.from(() ->
        {
            // When the buffer has been consumed, then:
            // A) succeed the nested callback.
            callback.succeeded();
            // B) demand more DATA frames.
            stream.demand(1);
        }, callback::failed)));

        // Do not demand more content here, to avoid to overflow the queue.
    }
};
Applications that implement onDataDemanded(…​) must remember to call Stream.demand(…​). If they don’t, the implementation will not deliver DATA frames and the application will stall threadlessly until an idle timeout fires to close the stream or the session.

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.Adapter()
{
    @Override
    public void onReset(Stream stream, ResetFrame frame)
    {
        // The server reset this stream.
    }
});
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.Adapter()
{
    @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.getURI();
        // The pushed "request" headers.
        HttpFields pushedRequestHeaders = pushedRequest.getFields();

        // 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.Adapter()
        {
            @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.getFields();
                }
            }

            @Override
            public void onData(Stream stream, DataFrame frame, Callback callback)
            {
                // Handle the pushed stream "response" content.

                // The pushed stream "response" content bytes.
                ByteBuffer buffer = frame.getData();
                // Consume the buffer and complete the callback.
                callback.succeeded();
            }
        };
    }
});

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.Adapter()
{
    @Override
    public Stream.Listener onPush(Stream pushedStream, PushPromiseFrame frame)
    {
        // Reset the pushed stream to tell the server we 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;
    }
});