nhttp_h2 (nhttp_lib v1.2.1)

View Source

HTTP/2 protocol layer.

This module implements RFC 9113 HTTP/2 connection and stream state machines. It sits between the framing layer (nhttp_h2_frame) and the application layer, providing:

  • Connection lifecycle management (preface, settings, shutdown)
  • Stream state machine (RFC 9113 Section 5.1)
  • Flow control (connection and stream level)
  • Header block assembly (CONTINUATION handling)
  • Error handling (connection vs stream errors)
  • Stream turnover accounting (RFC 9113 Section 10.5)

Usage

Conn0 = nhttp_h2:new(client),
Preface = nhttp_h2:preface(Conn0),
ok = ssl:send(Socket, Preface),

{ok, Events, Conn1} = nhttp_h2:recv(Conn0, Data),
lists:foreach(fun handle_event/1, Events),

{ok, Conn2, Frames} = nhttp_h2:send_headers(Conn1, StreamId, Headers, fin),
ok = ssl:send(Socket, Frames).

Sending bodies and trailers

The HTTP/2 send surface is frame-oriented. The canonical nhttp_lib:request/0 and nhttp_lib:response/0 maps can carry a body and trailers field as a convenience for "everything is in memory", but this layer does not consume those maps directly: the caller breaks the exchange into discrete frame sends. The pattern is:

  1. send_headers(Conn, StreamId, Headers, nofin) to emit pseudo-headers plus regular headers (HEADERS + optional CONTINUATION).
  2. Zero or more send_data(Conn, StreamId, Chunk, nofin) calls.
  3. Either send_data(Conn, StreamId, FinalChunk, fin) to close on body, or send_headers(Conn, StreamId, Trailers, fin) to close on trailers.

For a one-shot send when the body is already a single iodata(), call send_headers/4 with nofin followed by send_data/4 with fin. Flow-control and END_STREAM ride on the DATA frame.

Send queue

With send_queue => true, send_data/4 holds what the credit cannot take and answers {queued, Conn, Frames, Buffered}. recv/2 emits the held octets as credit arrives, after its control frames, round-robin with one frame per stream per turn, and reports {data_sent, StreamId, Bytes, Fin} per stream. The caller MUST write every iodata the codec returns, in order, from one process.

max_send_buffer (128 KiB) and max_queued_streams (a quarter of the peer SETTINGS_MAX_CONCURRENT_STREAMS) refuse an offer as {error, send_buffer_full}. RST_STREAM in either direction and GOAWAY purge the queue without restoring credit. A queued fin leaves the stream open until the frame goes out, and send_headers/4 behind queued data answers {error, {data_pending, StreamId}}.

Summary

Types

Events that recv/2 reports.

Reasons a send function refuses a call.

Connection settings.

Functions

Return the connection send window in octets.

Return the number of live entries in the dynamic table of the HPACK encoder.

Return the size of the dynamic table of the HPACK encoder in octets.

Create a new HTTP/2 connection with default settings.

Create a new HTTP/2 connection with custom settings.

Open a new stream and return its ID.

Return the settings the peer sent, merged over the defaults.

Generate the connection preface for this role. Client sends: magic + SETTINGS. Server sends: SETTINGS.

Return the number of streams with an entry in the send queue.

Process incoming data and return events.

Return the octets the send queue holds on the connection.

Return the octets the send queue holds for one stream, 0 without an entry.

Send every DATA frame that the current credit allows.

Send GOAWAY to initiate graceful shutdown.

Send HEADERS frame for a new request/response or trailers.

Send PING frame with caller-supplied 8-byte opaque data. The caller is responsible for generating the opaque value (e.g. via crypto:strong_rand_bytes(8)) and matching it against the PING_ACK event.

Send RST_STREAM to cancel a stream.

Send WINDOW_UPDATE for the connection or for a stream.

Record the peer address on the connection. Called once after the socket is accepted (or connected) so server-built nhttp_lib:request/0 maps and client-built nhttp_lib:response/0 events carry the correct remote peer.

Return the send window of one stream in octets.

Return the stream counters for this connection. active is the number of streams in the "open" state or in either "half-closed" state, the set that SETTINGS_MAX_CONCURRENT_STREAMS bounds (RFC 9113 Section 5.1.2). peer_opened is the total number of streams that the peer opened. peer_reset is the number of streams that the peer terminated with RST_STREAM. SETTINGS_MAX_CONCURRENT_STREAMS bounds the streams that are open at one instant. It does not bound stream turnover. A peer that alternates HEADERS and RST_STREAM holds active at a low value and drives peer_opened and peer_reset without limit. RFC 9113 Section 10.5 tells an implementation to track such use and to set a limit on it. This library holds no clock, so it counts events only. The caller reads these counters to apply a rate per unit of time. To let the connection refuse the peer on its own, set max_reset_streams in the local settings. The connection then fails with a connection error of type ENHANCE_YOUR_CALM when peer_reset is more than max_reset_streams + (peer_opened div 2). The default is infinity, which counts without a limit.

Types

conn()

-opaque conn()

error_code()

-type error_code() :: nhttp_lib:error_code().

event()

-type event() ::
          nhttp_lib:event_common() |
          {stream_closed, nhttp_lib:stream_id(), error_code()} |
          {stream_refused, nhttp_lib:stream_id()} |
          {window_update, nhttp_lib:stream_id(), pos_integer()} |
          {data_sent, nhttp_lib:stream_id(), non_neg_integer(), fin()} |
          {settings, settings()} |
          settings_ack |
          {ping, binary()} |
          {ping_ack, binary()}.

Events that recv/2 reports.

A window_update event mirrors one WINDOWUPDATE frame from the peer, with stream id 0 for the connection window. A SETTINGS frame that changes initial_window_size adjusts every stream send window inside the codec and reports `{settings, }only.connection_send_window/1andstream_send_window/2` read the adjusted windows.

A data_sent event reports the octets the send queue emitted for a stream in this call, with Fin fin when the END_STREAM frame went out.

fin()

-type fin() :: nhttp_lib:fin().

priority()

-type priority() ::
          #{exclusive := boolean(), stream_dependency := nhttp_lib:stream_id(), weight := 1..256}.

recv_result()

-type recv_result() ::
          {ok, [event()], conn()} |
          {ok, [event()], conn(), iodata()} |
          {error, nhttp_h2_frame:decode_error()}.

role()

-type role() :: nhttp_lib:role().

send_error()

-type send_error() ::
          connection_closing | send_buffer_full |
          {data_pending, nhttp_lib:stream_id()} |
          {unknown_stream, nhttp_lib:stream_id()} |
          {stream_closed, nhttp_lib:stream_id()} |
          {stream_error, nhttp_lib:stream_id(), error_code(), binary()} |
          {recv_window_overflow, nhttp_lib:stream_id() | connection}.

Reasons a send function refuses a call.

{recv_window_overflow, Target} reports that the increment given to send_window_update/3 takes the local receive window of Target past 2^31-1 (RFC 9113 Section 6.9.1). The codec sent nothing, the peer saw nothing, and the connection stays open.

send_buffer_full reports an offer past max_send_buffer or max_queued_streams, and {data_pending, StreamId} a HEADERS frame on a stream with octets in the send queue. Nothing changed in either case.

send_result()

-type send_result() ::
          {ok, conn(), iodata()} |
          {partial, conn(), iodata(), binary(), fin(), Window :: integer()} |
          {queued, conn(), iodata(), Buffered :: pos_integer()} |
          {error, send_error()}.

settings()

-type settings() ::
          #{header_table_size => non_neg_integer(),
            enable_push => boolean(),
            max_concurrent_streams => pos_integer() | infinity,
            initial_window_size => 1..2147483647,
            max_frame_size => 16384..16777215,
            max_header_list_size => pos_integer() | infinity,
            max_continuation_frames => pos_integer() | infinity,
            enable_connect_protocol => boolean(),
            max_reset_streams => pos_integer() | infinity,
            hpack_index_policy => nhttp_hpack:index_policy(),
            send_queue => boolean(),
            max_send_buffer => pos_integer() | infinity,
            max_queued_streams => pos_integer() | infinity}.

Connection settings.

Most keys map to a SETTINGS parameter of RFC 9113 Section 6.5.2 and go on the wire. max_continuation_frames, max_reset_streams, hpack_index_policy, send_queue, max_send_buffer and max_queued_streams are local policy. They have no wire representation, and the encoder drops them. The first two bound work that a peer can request. hpack_index_policy reaches nhttp_hpack:new/2 and names the fields that the HPACK encoder keeps out of its dynamic table. The nhttp_hpack module documentation gives the two literal forms and the sets that fit a deployment. send_queue, max_send_buffer and max_queued_streams configure the send queue of the module documentation.

max_continuation_frames bounds the number of CONTINUATION frames in one field section, per RFC 9113 Section 10.5. An empty CONTINUATION frame adds no bytes, so max_header_list_size alone does not bound it. When the key is absent, the bound is ceil(max_header_list_size / max_frame_size) + 8, and it is infinity when max_header_list_size is infinity. The derived value tracks the byte bound, so a caller that raises max_header_list_size keeps the CONTINUATION frames that carry the larger field section.

stream_state()

-type stream_state() ::
          idle | reserved_local | reserved_remote | open | half_closed_local | half_closed_remote |
          closed.

Functions

connection_send_window/1

-spec connection_send_window(conn()) -> integer().

Return the connection send window in octets.

This is the credit the peer granted for DATA payloads on every stream of the connection, less what send_data/4 spent (RFC 9113 Section 6.9.1). The value is negative after a SETTINGS_INITIAL_WINDOW_SIZE shrink that overtakes the credit already spent, and send_data/4 sends nothing until WINDOW_UPDATE takes it positive again.

encoder_table_entries/1

-spec encoder_table_entries(conn()) -> non_neg_integer().

Return the number of live entries in the dynamic table of the HPACK encoder.

The count moves with every field that send_headers/4 inserts and with every eviction that the insert forces (RFC 7541 Section 4.4). A field whose name is in hpack_index_policy never inserts, so a caller that tunes the policy reads its effect here.

encoder_table_size/1

-spec encoder_table_size(conn()) -> non_neg_integer().

Return the size of the dynamic table of the HPACK encoder in octets.

The size counts every live entry as name, value and the 32 octets of overhead of RFC 7541 Section 4.1. It never exceeds the SETTINGS_HEADER_TABLE_SIZE that the peer sent.

new(Role)

-spec new(role()) -> conn().

Create a new HTTP/2 connection with default settings.

new(Role, LocalSettings)

-spec new(role(), settings()) -> conn().

Create a new HTTP/2 connection with custom settings.

open_stream/1

-spec open_stream(conn()) ->
                     {ok, nhttp_lib:stream_id(), conn()} |
                     {error, connection_closing | max_streams_reached | stream_ids_exhausted}.

Open a new stream and return its ID.

Stream identifiers grow by two per stream and end at 2^31-1. When the next identifier is past that bound, the call answers {error, stream_ids_exhausted} and leaves the connection unchanged. The caller must open a new connection for further requests (RFC 9113 Section 5.1.1).

peer_settings/1

-spec peer_settings(conn()) -> settings().

Return the settings the peer sent, merged over the defaults.

The map carries the last value of every SETTINGS parameter the peer sent, and the default of RFC 9113 Section 6.5.2 for the rest. initial_window_size is the send window that a new stream starts with, and max_frame_size bounds the payload of one DATA frame.

preface/1

-spec preface(conn()) -> iodata().

Generate the connection preface for this role. Client sends: magic + SETTINGS. Server sends: SETTINGS.

queued_streams/1

-spec queued_streams(conn()) -> non_neg_integer().

Return the number of streams with an entry in the send queue.

recv/2

-spec recv(conn(), binary()) -> recv_result().

Process incoming data and return events.

The four-tuple arm carries frames for the caller to write: SETTINGS ACK, PING ACK and RST_STREAM, then the DATA frames the send queue emitted.

send_buffer_bytes/1

-spec send_buffer_bytes(conn()) -> non_neg_integer().

Return the octets the send queue holds on the connection.

send_buffer_bytes/2

-spec send_buffer_bytes(conn(), nhttp_lib:stream_id()) -> non_neg_integer().

Return the octets the send queue holds for one stream, 0 without an entry.

send_data/4

-spec send_data(conn(), nhttp_lib:stream_id(), iodata(), fin()) -> send_result().

Send every DATA frame that the current credit allows.

The payload is split at the peer max_frame_size and bounded by the connection send window and the stream send window. A payload that fits in the credit comes back as {ok, Conn, Frames}. Frames is one DATA frame or a list of DATA frames, and END_STREAM sits on the last frame only. A payload that outruns the credit comes back as {partial, Conn, Frames, Rest, EndStream, Window}. Frames carries every frame the credit paid for and none of them carries END_STREAM. Rest is the remainder the caller must offer again after WINDOW_UPDATE, and Window is the credit that is left, zero or below. A {partial, ...} return therefore means one thing: the credit ran out.

The frames share the octets of the payload. Rest is a copy when the payload is more than four times its size, so a small remainder does not retain a large payload.

An empty payload with fin is sent at any window value, because a frame without payload consumes no flow-control credit (RFC 9113 Section 6.9.1). An empty payload with nofin at a window of zero or below is held back as {partial, ...}. The frame is legal, but it carries nothing, so the codec declines to spend a frame on it.

With send_queue => true the call never answers {partial, ...}. A payload that outruns the credit comes back as {queued, Conn, Frames, Buffered}, where Buffered is the octet count of this stream that the codec holds. An empty payload with nofin answers {ok, Conn, []} at any window, an empty fin behind pending octets rides the last drained frame, and a stream whose pending entry carries fin answers {error, {stream_closed, StreamId}}.

send_goaway/3

-spec send_goaway(conn(), error_code(), binary()) -> send_result().

Send GOAWAY to initiate graceful shutdown.

send_headers/4

-spec send_headers(conn(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin()) ->
                      {ok, conn(), iodata()} | {error, send_error()}.

Send HEADERS frame for a new request/response or trailers.

A stream with octets in the send queue answers {error, {data_pending, StreamId}}.

send_ping/2

-spec send_ping(conn(), <<_:64>>) -> send_result().

Send PING frame with caller-supplied 8-byte opaque data. The caller is responsible for generating the opaque value (e.g. via crypto:strong_rand_bytes(8)) and matching it against the PING_ACK event.

send_rst_stream/3

-spec send_rst_stream(conn(), nhttp_lib:stream_id(), error_code()) -> send_result().

Send RST_STREAM to cancel a stream.

The call removes the stream and purges its send queue entry. The purge changes no window: octets already emitted stay debited until the peer credits stream 0 (RFC 9113 Section 6.9.1).

send_window_update/3

-spec send_window_update(conn(), nhttp_lib:stream_id() | connection, pos_integer()) -> send_result().

Send WINDOW_UPDATE for the connection or for a stream.

The increment is added to the local receive window. An increment that takes that window past 2^31-1 is refused as {recv_window_overflow, Target} and nothing is sent (RFC 9113 Section 6.9.1). A stream that the codec no longer tracks answers {ok, Conn, []}: the stream closed, no credit is owed, and the peer ignores a frame on a closed stream (RFC 9113 Section 5.1).

set_peer/2

-spec set_peer(conn(), nhttp_lib:peer()) -> conn().

Record the peer address on the connection. Called once after the socket is accepted (or connected) so server-built nhttp_lib:request/0 maps and client-built nhttp_lib:response/0 events carry the correct remote peer.

stream_send_window/2

-spec stream_send_window(conn(), nhttp_lib:stream_id()) ->
                            {ok, integer()} | {error, {unknown_stream, nhttp_lib:stream_id()}}.

Return the send window of one stream in octets.

The value is the credit the peer granted for DATA payloads on that stream, less what send_data/4 spent (RFC 9113 Section 6.9.1). A stream that open_stream/1 created and send_headers/4 has not yet moved out of idle carries a window too, and a SETTINGS_INITIAL_WINDOW_SIZE change reaches it. A stream the codec does not track answers {error, {unknown_stream, Id}}.

stream_stats/1

-spec stream_stats(conn()) ->
                      #{active := non_neg_integer(),
                        peer_opened := non_neg_integer(),
                        peer_reset := non_neg_integer()}.

Return the stream counters for this connection. active is the number of streams in the "open" state or in either "half-closed" state, the set that SETTINGS_MAX_CONCURRENT_STREAMS bounds (RFC 9113 Section 5.1.2). peer_opened is the total number of streams that the peer opened. peer_reset is the number of streams that the peer terminated with RST_STREAM. SETTINGS_MAX_CONCURRENT_STREAMS bounds the streams that are open at one instant. It does not bound stream turnover. A peer that alternates HEADERS and RST_STREAM holds active at a low value and drives peer_opened and peer_reset without limit. RFC 9113 Section 10.5 tells an implementation to track such use and to set a limit on it. This library holds no clock, so it counts events only. The caller reads these counters to apply a rate per unit of time. To let the connection refuse the peer on its own, set max_reset_streams in the local settings. The connection then fails with a connection error of type ENHANCE_YOUR_CALM when peer_reset is more than max_reset_streams + (peer_opened div 2). The default is infinity, which counts without a limit.