Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## unreleased

### Added

- Listener options `h2_initial_window_size` and `h2_max_frame_size`, aliases of the `h2_settings` keys
- Listener option `h2_response_delay`, a fixed or uniform delay before the response headers of a `{reply, _, _}` result on HTTP/2
- Listener options `h2_connection_window_policy` and `h2_stream_window_policy`, credit policies for the HTTP/2 receive windows with the shapes `eager`, `{threshold, N}`, `{delay, Ms}` and `never`
- The `on_response` shape of both credit policies, which sends the credit for a request ahead of the HEADERS of its `{reply, _, _}` response, and the listener option `h2_credit_batch` that batches the connection credit

### Changed

- Updates `nhttp_lib` reference

## [1.1.1] - 2026-09-10

### Changed
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,55 @@ HTTP/3 runs over QUIC on a UDP socket. HTTP/1.1 and HTTP/2 run over TCP on their
- **Graceful shutdown** with connection draining
- **One process per connection**, with HTTP/2 and HTTP/3 streams multiplexed inside it

### HTTP/2 test affordances

These listener options exist so that a client can be tested against a
slow or stingy HTTP/2 peer. Each one defaults to the current behavior and
changes nothing on HTTP/1.1 or HTTP/3.

| Option | Default | Effect |
|--------|---------|--------|
| `h2_initial_window_size` | 65535 | Alias of `initial_window_size` in `h2_settings`: the receive window that each new stream grants the peer. |
| `h2_max_frame_size` | 16384 | Alias of `max_frame_size` in `h2_settings`: the largest frame payload the server accepts. |
| `h2_response_delay` | 0 | Milliseconds to hold the response headers of a `{reply, _, _}` result. `{uniform, MinMs, MaxMs}` draws a value per response. |
| `h2_connection_window_policy` | `eager` | Credit policy for the connection receive window. |
| `h2_stream_window_policy` | `eager` | Credit policy for each stream receive window. |
| `h2_credit_batch` | 0 | Octets per WINDOW_UPDATE under the `on_response` connection policy. 0 sends the whole accumulator with each response. Valid only when a policy is `on_response`. |

An alias must equal the `h2_settings` key when both are present. The
delay applies to `{reply, _, _}` results only. Error responses, producer
streams and WebSocket upgrades go out at once.

A credit policy has five shapes. `eager` sends a WINDOW_UPDATE for each
body chunk as soon as the handler consumed it. `{threshold, N}` holds the
credit until the uncredited octets reach `N`, then sends the accumulated
total. `{delay, Ms}` sends the credit for each chunk `Ms` milliseconds
after the handler consumed it. `on_response` holds the credit of a request
until the HEADERS of its `{reply, _, _}` result go out. The credit goes in
the same socket write, ahead of the HEADERS. `never` sends no credit.

The two policies are independent. No policy credits more than the handler
consumed. Error responses, `{abort, _, _}` results and stream resets
release no `on_response` credit. A stream that closes before its delayed
credit is due gets no stream WINDOW_UPDATE. The connection credit for
those octets still goes out.

Under the `on_response` connection policy the responded octets move into
one accumulator. Each time the accumulator reaches `h2_credit_batch`, one
WINDOW_UPDATE of exactly that size goes out with the response, and the
remainder carries. This example imitates APNs, which credits half of its
window at a time, sends no stream credit, and answers 129 to 388 ms after
the request:

```erlang
#{
h2_connection_window_policy => on_response,
h2_credit_batch => 32830,
h2_stream_window_policy => never,
h2_response_delay => {uniform, 129, 388}
}
```

## Documentation

[nhttp on HexDocs](https://hexdocs.pm/nhttp)
Expand Down
4 changes: 3 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
{deps, [
{opentelemetry_api, "1.4.0"},
{opentelemetry_api_experimental, "0.5.1"},
{nhttp_lib, "1.1.1"},
{nhttp_lib,
{git, "https://github.com/nomasystems/nhttp_lib.git",
{ref, "14e36f584e70022a240135b961d203e504693343"}}},
{nquic, "1.0.3"}
]}.

Expand Down
7 changes: 4 additions & 3 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
{"1.2.0",
[{<<"nhttp_lib">>,{pkg,<<"nhttp_lib">>,<<"1.1.1">>},0},
[{<<"nhttp_lib">>,
{git,"https://github.com/nomasystems/nhttp_lib.git",
{ref,"14e36f584e70022a240135b961d203e504693343"}},
0},
{<<"nquic">>,{pkg,<<"nquic">>,<<"1.0.3">>},0},
{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.4.0">>},0},
{<<"opentelemetry_api_experimental">>,
{pkg,<<"opentelemetry_api_experimental">>,<<"0.5.1">>},
0}]}.
[
{pkg_hash,[
{<<"nhttp_lib">>, <<"B76860C6571AC8F3B2890B5FEFB5A4F593CBDAEF76A1783C6519D91AAE031904">>},
{<<"nquic">>, <<"1B25FD940CDF4BD7378B360484FA546D7967F60D8421CFDBEDD0774F6398D1D4">>},
{<<"opentelemetry_api">>, <<"63CA1742F92F00059298F478048DFB826F4B20D49534493D6919A0DB39B6DB04">>},
{<<"opentelemetry_api_experimental">>, <<"1B5AFACFCBD0834390336C845BC8AE08C8CF0D69BBED72EE53D178798B93E074">>}]},
{pkg_hash_ext,[
{<<"nhttp_lib">>, <<"DD3790FEF5AC2F4494D8DDD373053069641B753F717F403F38DA3B817FD84F82">>},
{<<"nquic">>, <<"73E9675F9D1C1B2F182A961B3C689778CAD8BA178C63E57C6A23A1C5FDE427F2">>},
{<<"opentelemetry_api">>, <<"3DFBBFAA2C2ED3121C5C483162836C4F9027DEF469C41578AF5EF32589FCFC58">>},
{<<"opentelemetry_api_experimental">>, <<"10297057EADA47267D4F832011BECEF07D25690E6BF91FEBCCFC4E740DBA1A6F">>}]}
Expand Down
2 changes: 1 addition & 1 deletion src/nhttp.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, nhttp, [
{description, "HTTP/1.1, HTTP/2, and HTTP/3 server for Erlang/OTP 27+"},
{vsn, "1.1.1"},
{vsn, "1.2.0"},
{registered, []},
{applications, [
kernel,
Expand Down
32 changes: 32 additions & 0 deletions src/nhttp.erl
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ RFC 9001 §9.2).
header_name/0,
header_value/0,
headers/0,
h2_response_delay/0,
h2_window_policy/0,

method/0,
name/0,
Expand Down Expand Up @@ -197,7 +199,13 @@ RFC 9001 §9.2).
alt_svc => #{ma => non_neg_integer()} | false,
backlog => pos_integer(),
buffer => pos_integer(),
h2_connection_window_policy => h2_window_policy(),
h2_credit_batch => non_neg_integer(),
h2_initial_window_size => 1..16#7fffffff,
h2_max_frame_size => 16#4000..16#ffffff,
h2_response_delay => h2_response_delay(),
h2_settings => nhttp_h2:settings(),
h2_stream_window_policy => h2_window_policy(),
handler := module(),
handler_args => term(),
compression => boolean(),
Expand All @@ -212,6 +220,30 @@ RFC 9001 §9.2).
proxy_protocol => boolean() | proxy_protocol_opts()
}.

-doc """
Delay before the response headers of a `{reply, _, _}` handler result on
HTTP/2, in milliseconds. `{uniform, MinMs, MaxMs}` draws a value in that
range for each response. Error responses, producer streams and WebSocket
upgrades are not delayed. Default 0.
""".
-type h2_response_delay() ::
non_neg_integer() | {uniform, non_neg_integer(), non_neg_integer()}.

-doc """
Credit policy for an HTTP/2 receive window. `eager` sends a WINDOW_UPDATE
for each body chunk as soon as the handler consumed it. `{threshold, N}`
holds the credit until the uncredited octets reach `N`, then sends the
accumulated total. `{delay, Ms}` sends the credit for each chunk `Ms`
milliseconds after the handler consumed it. `on_response` sends the
credit for the body of a request ahead of the response headers of its
`{reply, _, _}` result. On the connection window `h2_credit_batch`
holds that credit until the responded octets reach the batch, then
sends one WINDOW_UPDATE of exactly the batch. `never` sends no credit.
Default `eager`.
""".
-type h2_window_policy() ::
eager | {threshold, pos_integer()} | {delay, non_neg_integer()} | on_response | never.

-doc """
Connection timeouts (milliseconds, or `infinity`).

Expand Down
52 changes: 48 additions & 4 deletions src/nhttp_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -421,24 +421,68 @@ h1_state_from_opts(Opts) ->
body_deadline = maps:get(body_deadline, Timeouts, infinity)
}.

-spec h2_conn_credit(nhttp:h2_window_policy(), non_neg_integer()) -> h2_conn_credit().
h2_conn_credit(eager, _Batch) -> eager;
h2_conn_credit(never, _Batch) -> never;
h2_conn_credit({threshold, N}, _Batch) -> {threshold, N, 0};
h2_conn_credit({delay, Ms}, _Batch) -> {delay, Ms, queue:new()};
h2_conn_credit(on_response, Batch) -> {on_response, Batch, 0}.

-doc """
Build the HTTP/2 settings the codec advertises. The first-class options
`h2_initial_window_size` and `h2_max_frame_size` are aliases of the
`h2_settings` keys and are resolved here, the only place the codec is built.
""".
-spec h2_settings_from_opts(nhttp:opts()) -> nhttp_h2:settings().
h2_settings_from_opts(Opts) ->
Settings = maps:fold(
fun put_h2_alias/3,
maps:get(h2_settings, Opts, #{}),
maps:with([h2_initial_window_size, h2_max_frame_size], Opts)
),
Settings#{enable_connect_protocol => true}.

-spec h2_stream_credit(nhttp:h2_window_policy()) -> h2_stream_credit().
h2_stream_credit(eager) -> eager;
h2_stream_credit(never) -> never;
h2_stream_credit({threshold, N}) -> {threshold, N};
h2_stream_credit({delay, Ms}) -> {delay, Ms, queue:new()};
h2_stream_credit(on_response) -> on_response.

-spec init_protocol(#state{}) -> {ok, #state{}} | {error, nhttp_sock:socket_error()}.
init_protocol(#state{family = http2, socket = Socket, peer = Peer, opts = Opts} = State) ->
UserH2Settings = maps:get(h2_settings, Opts, #{}),
H2Settings = UserH2Settings#{enable_connect_protocol => true},
H2Conn0 = nhttp_h2:new(server, H2Settings),
H2Conn0 = nhttp_h2:new(server, h2_settings_from_opts(Opts)),
H2Conn = nhttp_h2:set_peer(H2Conn0, Peer),
Preface = nhttp_h2:preface(H2Conn),
maybe
ok ?= nhttp_sock:send(Socket, Preface),
ok ?= nhttp_sock:setopts(Socket, [{active, once}]),
{ok, State#state{protocol_state = #h2_state{h2_conn = H2Conn}}}
{ok, State#state{
protocol_state = #h2_state{
h2_conn = H2Conn,
conn_credit = h2_conn_credit(
maps:get(h2_connection_window_policy, Opts, eager),
maps:get(h2_credit_batch, Opts, 0)
),
response_delay = maps:get(h2_response_delay, Opts, 0),
stream_credit = h2_stream_credit(maps:get(h2_stream_window_policy, Opts, eager))
}
}}
end;
init_protocol(#state{family = http1, socket = Socket, opts = Opts} = State) ->
case nhttp_sock:setopts(Socket, [{active, once}]) of
ok -> {ok, State#state{protocol_state = h1_state_from_opts(Opts)}};
{error, _} = Error -> Error
end.

-spec put_h2_alias(
h2_initial_window_size | h2_max_frame_size, pos_integer(), nhttp_h2:settings()
) -> nhttp_h2:settings().
put_h2_alias(h2_initial_window_size, Value, Settings) ->
Settings#{initial_window_size => Value};
put_h2_alias(h2_max_frame_size, Value, Settings) ->
Settings#{max_frame_size => Value}.

-spec version_to_family(nhttp_lib:version()) -> http1 | http2 | http3.
version_to_family(http1_0) -> http1;
version_to_family(http1_1) -> http1;
Expand Down
25 changes: 23 additions & 2 deletions src/nhttp_conn.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@
streaming_body = false :: boolean(),
pending_trailers :: nhttp_lib:headers() | undefined,
body_window_pending = queue:new() :: queue:queue(non_neg_integer()),
uncredited = 0 :: non_neg_integer(),
conn_uncredited = 0 :: non_neg_integer(),
status :: nhttp_lib:status() | undefined,
req_span :: {nhttp_otel:span_ctx(), integer()} | undefined,
bytes_sent = 0 :: non_neg_integer(),
response_started = false :: boolean(),
request :: nhttp_lib:request() | undefined
request :: nhttp_lib:request() | undefined,
held_response :: {reference(), nhttp_lib:response()} | undefined
}).

-record(h1_push_ctx, {
Expand All @@ -71,11 +74,29 @@
body_deadline_at :: integer() | undefined
}).

-type h2_credit_due() :: queue:queue({integer(), nhttp_lib:stream_id(), non_neg_integer()}).
-type h2_conn_credit() ::
eager
| never
| {threshold, pos_integer(), non_neg_integer()}
| {delay, non_neg_integer(), h2_credit_due()}
| {on_response, non_neg_integer(), non_neg_integer()}.
-type h2_stream_credit() ::
eager
| never
| {threshold, pos_integer()}
| {delay, non_neg_integer(), h2_credit_due()}
| on_response.

-record(h2_state, {
h2_conn :: nhttp_h2:conn(),
h2_streams = #{} :: #{nhttp_lib:stream_id() => #h2_stream{}},
h2_workers = #{} :: #{pid() => nhttp_lib:stream_id()},
drain_deadline :: integer() | undefined
drain_deadline :: integer() | undefined,
conn_credit = eager :: h2_conn_credit(),
credit_timer :: {reference(), integer()} | undefined,
response_delay = 0 :: nhttp:h2_response_delay(),
stream_credit = eager :: h2_stream_credit()
}).

-record(state, {
Expand Down
Loading
Loading