WebSockets over http/2: implementing RFC8441 with Netty

July 30, 2020
netty websocket http2 java

Today I’d like to introduce you to netty-websocket-http2 - implementation of websockets-over-http2, first release of which is about to land on the Maven Central.

Novel protocol graduated out of draft phase in September 2018, and is one of the smallest RFCs I’ve ever seen - just 4 pages of actual specification.

Despite tiny size It opens some interesting possibilities - for both clients and servers.

Protocol defines Extended CONNECT Method - mechanism for establishing byte stream tunnel over single http2 stream, and addresses biggest flaw of websocket over http1 - need for a separate tcp connection per websocket.

This benefits servers because clients use single connection for both http and rpc calls; lowers client latency since tcp & tls handshake steps are eliminated; enables simpler gateways - same software stack for internet traffic implemented in terms of http2 streams; adds priority support for http/rpc requests of the same connection.

One drawback is limited widespread - the only first-tier browser having protocol support is Mozilla Firefox. However libwebsockets - popular C networking library - also supports RFC8441, which means It becomes viable option if primary clients are native mobile.

Update from May 2021.
Chrome web browser supports websockets-over-http2, which together with Firefox constitutes ~85% of web clients. On server side protocol is supported by haproxy and envoy proxy.

Scope

Library is addressing 2 use cases: for application servers and clients, It is transparent use of existing http1 websocket handlers on top of http2 streams; for gateways/proxies, It is websockets-over-http2 support with no http1 dependencies and minimal overhead.

websocket channel API

Intended for application servers and clients.
Allows transparent usage of existing http1 websocket handlers on top of http2 stream.

EchoWebSocketHandler http1WebSocketHandler = new EchoWebSocketHandler();

 Http2WebSocketServerHandler http2webSocketHandler =
       Http2WebSocketServerBuilder.create()
              .acceptor(
                   (ctx, path, subprotocols, request, response) -> {
                     switch (path) {
                       case "/echo":
                         if (subprotocols.contains("echo.jauntsdn.com")
                             && acceptUserAgent(request, response)) {
                           /*selecting subprotocol for accepted requests is mandatory*/
                           Http2WebSocketAcceptor.Subprotocol
                                  .accept("echo.jauntsdn.com", response);
                           return ctx.executor()
                                  .newSucceededFuture(http1WebSocketHandler);
                         }
                         break;
                       case "/echo_all":
                         if (subprotocols.isEmpty() 
                                  && acceptUserAgent(request, response)) {
                           return ctx.executor()
                                  .newSucceededFuture(http1WebSocketHandler);
                         }
                         break;
                     }
                     return ctx.executor()
                         .newFailedFuture(
                             new WebSocketHandshakeException(
                                  "websocket rejected, path: " + path));
                   })
              .build();

      ch.pipeline()
           .addLast(sslHandler, 
                    http2frameCodec, 
                    http2webSocketHandler);
 Channel channel =
        new Bootstrap()
            .handler(
                new ChannelInitializer<SocketChannel>() {
                  @Override
                  protected void initChannel(SocketChannel ch) {

                    Http2WebSocketClientHandler http2WebSocketClientHandler =
                        Http2WebSocketClientBuilder.create()
                            .handshakeTimeoutMillis(15_000)
                            .build();

                    ch.pipeline()
                        .addLast(
                            sslHandler,
                            http2FrameCodec,
                            http2WebSocketClientHandler);
                  }
                })
            .connect(address)
            .sync()
            .channel();

Http2WebSocketClientHandshaker handShaker = 
            Http2WebSocketClientHandshaker.create(channel);

Http2Headers headers =
   new DefaultHttp2Headers()
            .set("user-agent", "jauntsdn-websocket-http2-client/1.1.2");

ChannelFuture handshake =
   /*http1 websocket handler*/
   handShaker.handshake("/echo", headers, new EchoWebSocketHandler());
    
handshake.channel().writeAndFlush(new TextWebSocketFrame("hello http2 websocket"));

Successfully handshaked http2 stream spawns websocket subchannel, and provided http1 websocket handlers are added to its pipeline.

Runnable demo is available in netty-websocket-http2-example module - channelserver, channelclient.

websocket handshake only API

Intended for intermediaries/proxies.
Only verifies whether http2 stream is valid websocket, then passes it down the pipeline as POST request with x-protocol=websocket header.

      Http2WebSocketServerHandler http2webSocketHandler =
          Http2WebSocketServerBuilder.buildHandshakeOnly();

      Http2StreamsHandler http2StreamsHandler = new Http2StreamsHandler();
      ch.pipeline()
           .addLast(sslHandler, 
                    frameCodec, 
                    http2webSocketHandler, 
                    http2StreamsHandler);

Works with both callbacks-style Http2ConnectionHandler and frames based Http2FrameCodec.

Runnable demo is available in netty-websocket-http2-example module - handshakeserver, channelclient.

compression & subprotocols

Server/client permessage-deflate compression configuration is shared by all streams

Http2WebSocketServerBuilder.compression(enabled);

or

Http2WebSocketServerBuilder.compression(
      compressionLevel,
      allowServerWindowSize,
      preferredClientWindowSize,
      allowServerNoContext,
      preferredClientNoContext);

Client subprotocols are configured on per-path basis

ChannelFuture handshake =
        handShaker.handshake("/echo", "subprotocol", headers, new EchoWebSocketHandler());

On a server It is responsibility of Http2WebSocketAcceptor to select supported protocol with

Http2WebSocketAcceptor.Subprotocol.accept(subprotocol, response);

lifecycle

Handshake events and several shutdown options are available when using Websocket channel style APIs.

handshake events

Events are fired on parent channel, also on websocket channel if one gets created

graceful shutdown

Outbound Http2WebSocketLocalCloseEvent on websocket channel pipeline shuts down http2 stream by sending empty DATA frame with END_STREAM flag set.

Graceful stream shutdown by remote is represented with inbound Http2WebSocketRemoteCloseEvent on websocket channel pipeline, graceful connection shutdown - with Http2WebSocketRemoteGoAwayEvent.

shutdown

Closing websocket channel terminates its http2 stream by sending RST frame.

validation & write error events

Both API style handlers send Http2WebSocketHandshakeErrorEvent for invalid websocket-over-http2 and http requests. For http2 frame write errors Http2WebSocketWriteErrorEvent is sent on parent channel if auto-close is not enabled; otherwise exception is delivered with ChannelPipeline.fireExceptionCaught followed by immediate close.

flow control

Inbound flow control is done automatically as soon as DATA frames are received. Library relies on netty’s DefaultHttp2LocalFlowController for refilling receive window.

Outbound flow control is expressed as websocket channels writability change on send window exhaust/refill, provided by DefaultHttp2RemoteFlowController.

websocket stream weight

Initial stream weight is configured with

Http2WebSocketClientBuilder.streamWeight(weight);

it can be updated by firing Http2WebSocketStreamWeightUpdateEvent on websocket channel pipeline. Currently blocked by netty bug.

performance

Library relies on capabilities provided by netty’s Http2ConnectionHandler so performance characteristics should be similar. netty-websocket-http2-perftest module contains application that gives rough throughput/latency estimate. The application is started with perf_server.sh, perf_client.sh.

On modern box one can expect following results for single websocket:

19:31:58.537 epollEventLoopGroup-2-1 com.jauntsdn.netty.handler.codec.http2.websocketx.perftest.client.Main p50 => 435 micros
19:31:58.537 epollEventLoopGroup-2-1 com.jauntsdn.netty.handler.codec.http2.websocketx.perftest.client.Main p95 => 662 micros
19:31:58.537 epollEventLoopGroup-2-1 com.jauntsdn.netty.handler.codec.http2.websocketx.perftest.client.Main p99 => 841 micros
19:31:58.537 epollEventLoopGroup-2-1 com.jauntsdn.netty.handler.codec.http2.websocketx.perftest.client.Main throughput => 205874 messages
19:31:58.537 epollEventLoopGroup-2-1 com.jauntsdn.netty.handler.codec.http2.websocketx.perftest.client.Main throughput => 201048.83 kbytes

To evaluate performance with multiple connections we compose an application comprised with simple echo server, and client sending batches of messages periodically over single websocket per connection (approximately models chat application).

With 25k active connections each sending batches of 5-10 messages of 0.2-0.5 KBytes over single websocket every 15-30seconds, the results are as follows (measured over time spans of 5 seconds):

11:32:44.080 pool-2-thread-1 com.jauntsdn.netty.handler.codec.http2.websocketx.stresstest.client.Main connection success   ==> 25000
11:32:44.080 pool-2-thread-1 com.jauntsdn.netty.handler.codec.http2.websocketx.stresstest.client.Main handshake success    ==> 25000
11:32:44.080 pool-2-thread-1 com.jauntsdn.netty.handler.codec.http2.websocketx.stresstest.client.Main messages p99, micros ==> 177
11:32:44.080 pool-2-thread-1 com.jauntsdn.netty.handler.codec.http2.websocketx.stresstest.client.Main messages p50, micros ==> 91

examples

netty-websocket-http2-example module contains demos showcasing both API styles, with this library/browser as clients.

browser example

Channelserver example serves web page at https://www.localhost:8099 that sends pings to /echo endpoint.

Currently only Mozilla Firefox and latest Google Chrome support websockets-over-http2.

Summary: alternative RSocket library for high performance network applications on JVM

July 6, 2021
RSocket java

Alternative RSocket-RPC: fast application services communication and transparent GRPC bridge

May 20, 2021
RSocket java grpc

RSocket-JVM: streamlining implementation for each vendor platform

April 22, 2021
RSocket java helidon