nhttp (nhttp v1.4.0)

View Source

Server public API for nhttp.

This module provides convenience functions for starting HTTP servers. For production use, embed nhttp_listener in your supervision tree.

Quick Start

{ok, Pid} = nhttp:start_link(#{
    port => 8080,
    handler => my_handler
}),

nhttp:stop(Pid).

Production Usage

For production, add the listener to your supervision tree:

ChildSpec = nhttp_listener:child_spec(my_http_listener, #{
    port => 8080,
    handler => my_handler
}),
{ok, {SupFlags, [ChildSpec | OtherChildren]}}.

TLS and SNI

A single TCP/TLS listener can serve several virtual hosts by attaching SNI options to the tls map (see tls/0). Both shapes are passed through to the underlying ssl server options unchanged.

Static lookup with sni_hosts:

{ok, _} = nhttp:start_link(my_server, #{
    port => 8443,
    handler => my_handler,
    versions => [http1_1, http2],
    tls => #{
        certfile  => "default.pem",
        keyfile   => "default.key",
        sni_hosts => [
            {"a.example.com",
                [{certfile, "vhost_a.pem"}, {keyfile, "vhost_a.key"}]},
            {"b.example.com",
                [{certfile, "vhost_b.pem"}, {keyfile, "vhost_b.key"}]}
        ]
    }
}).

Dynamic resolution with sni_fun (return undefined to fall back to the listener's default certfile/keyfile):

SniFun = fun(ServerName) ->
    case my_cert_store:lookup(ServerName) of
        {ok, CertOpts} -> CertOpts;
        not_found      -> undefined
    end
end,
{ok, _} = nhttp:start_link(my_server, #{
    port => 8443,
    handler => my_handler,
    versions => [http1_1, http2],
    tls => #{
        certfile => "default.pem",
        keyfile  => "default.key",
        sni_fun  => SniFun
    }
}).

sni_hosts and sni_fun may be combined and are TCP/TLS only. HTTP/3 listeners ignore them.

HTTP/2 in cleartext (h2c)

versions => [http2] without tls serves HTTP/2 over plain TCP with prior knowledge (RFC 9113 §3.3). The client sends the HTTP/2 connection preface first, as curl --http2-prior-knowledge and gRPC clients do. Upgrade: h2c is not supported. Requests carry the :scheme that the client sends.

{ok, Pid} = nhttp:start_link(my_srv, #{
    port     => 8080,
    handler  => my_handler,
    versions => [http2]
}).

versions => [http1_1, http2] without tls serves both on one port. Each connection gets HTTP/2 when its first 14 bytes are PRI * HTTP/2.0, the start of the connection preface (RFC 9113 §3.4), and HTTP/1.1 from the first byte that differs. A connection that sends no deciding bytes before the request timeout, or the idle timeout when it is shorter, is closed without a response.

Use cleartext HTTP/2 only behind a TLS terminator or inside a trusted network. http3 always needs tls.

Mixed h1/h2/h3 listeners

versions => [http1_1, http2, http3] serves all three from one call. h1 and h2 share a single TCP/TLS listener (ALPN-negotiated). h3 runs over a separate UDP/QUIC listener bound, by convention, on the same port number.

{ok, Pid} = nhttp:start_link(my_srv, #{
    port     => 443,
    handler  => my_handler,
    versions => [http1_1, http2, http3],
    tls      => #{certfile => "cert.pem", keyfile => "key.pem"}
}),
{ok, TcpPort}  = nhttp:get_port(Pid),         % h1/h2 (TCP)
{ok, QuicPort} = nhttp:get_port(Pid, quic).   % h3 (UDP)

max_connections applies per transport: a mixed listener admits up to max_connections TCP connections and up to max_connections QUIC connections, each with its own counter. The two never share a budget.

With port => 0 the TCP and UDP transports bind different ephemeral ports. get_port/1 returns the TCP port. Read the UDP port with get_port(Pid, quic) or get_ports/1. Never assume the two are equal.

Alt-Svc advertisement

A mixed listener auto-emits Alt-Svc: h3=":<quic_port>"; ma=86400 on every HTTP/1.1 and HTTP/2 response so clients discover and upgrade to h3 (RFC 7838 §3, RFC 9114 §3.1). With port => 0 the advertised port is the QUIC ephemeral port. Tune the freshness lifetime with alt_svc => #{ma => Seconds}, or disable advertisement with alt_svc => false. A handler overrides the header by setting its own alt-svc on the response, and suppresses it by setting alt-svc to an empty value.

Advertisement tracks live QUIC readiness, not static config (RFC 7838 §2.1). h3 is advertised only while the QUIC transport is accepting. Once it drains or goes down the response carries Alt-Svc: clear instead, so clients drop the cached alternative rather than waste a UDP attempt and fall back (RFC 7838 §4). Draining a mixed listener stops advertising before it stops accepting.

Response compression

A reply body of a type in compress_mime_types and at least compression_threshold bytes is gzip or deflate compressed when Accept-Encoding allows it. Each such response gets vary: accept-encoding, merged with any vary from the handler.

The body is sent unchanged when the handler set content-encoding, when cache-control holds no-transform, or when the status is 1xx, 204, 206 or 304. Streamed bodies are never compressed. HEAD gets the same headers as GET.

0-RTT

QUIC 0-RTT (early data) is not enabled: nquic refuses early data unless a replay-protection module is configured, which this server never sets. No application bytes are processed before the handshake completes, so a replayed non-idempotent method cannot reach the handler (RFC 8470, RFC 9001 §9.2).

Summary

Types

An erlang:spawn_opt/4 option for the processes that run handler code: the connection processes of HTTP/1.1, HTTP/2 and HTTP/3, and the stream workers. link and monitor are not allowed, because nhttp owns them. Default [].

Where handle_request/2 runs on HTTP/2. inline: in the connection process, in stream order, as on HTTP/1.1 and HTTP/3. A request with a body runs in a worker. worker: in one worker per stream. Default inline.

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.

Credit policy for an HTTP/2 receive window. eager sends a WINDOWUPDATE 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 windowh2_credit_batchholds that credit until the responded octets reach the batch, then sends one WINDOW_UPDATE of exactly the batch.neversends no credit. Defaulteager`.

Settings of the nquic listener that serves http3. nhttp passes them to nquic:listen/2, which checks the values.

Connection timeouts (milliseconds, or infinity).

Functions

Best-effort count of in-flight connections across every nhttp listener in this VM.

Count of in-flight requests across every nhttp listener in this VM. Backed by nhttp_otel's active-requests metric (always on for queries even when otel export is disabled).

Best-effort count of live WebSocket sessions across every nhttp listener in this VM. Incremented after the upgrade response is on the wire, decremented when the session terminates. Same brutal-kill caveat as active_connections/0.

Generate a child spec for embedding the listener in your supervisor with an auto-generated id. Use this form when you do not need to look the listener up by name later.

Generate a child spec for embedding the listener in your supervisor under a caller-supplied name. The supervisor child id is {nhttp_listener, Name} and the listener registers itself under Name when it starts.

Drain connections with the default 10-second timeout. Stops the acceptor sub-supervisor, signals every live connection to wind down, and blocks until they exit or the timeout elapses. The listener itself stays up. Call stop/1 afterwards to tear it down.

Drain connections with a custom timeout (milliseconds or infinity). See drain/1 for the behaviour. The listener itself stays up.

Get the TCP port a server is listening on. Returns the TCP/TLS transport's port. For a QUIC-only server it returns the sole transport's UDP port. Useful when port 0 was specified to get an ephemeral port. A mixed [http1_1, http2, http3] server binds two transports: TCP/TLS for h1/h2 and UDP/QUIC for h3. With port => 0 they bind different ephemeral ports. Use get_port/2 or get_ports/1 to read the UDP port.

Get the bound port for a specific transport (tcp or quic). tcp covers the h1/h2 listener, quic the h3 listener. Returns {error, {server, {no_transport, Kind}}} when the server does not serve that transport.

Get every bound transport port as a map keyed by transport. A single-transport server yields a one-entry map. A mixed server yields #{tcp => P1, quic => P2}. With port => 0 the two ports differ.

Look up a request header by name (case-insensitive). Top-level convenience wrapper over nhttp_req:header/2. Returns undefined when the header is missing.

Look up a request header by name (case-insensitive), with a default. Top-level convenience wrapper over nhttp_req:header/3.

Start an unnamed HTTP server linked to the calling process. Returns the supervisor pid. For production, use nhttp_listener:child_spec/1,2 to embed in your supervision tree instead. OpenTelemetry can be enabled via #{otel => true} or #{otel => #{traces => true, metrics => true}}.

Start a named HTTP server linked to the calling process. Name follows the standard gen_server registration shapes (atom(), {local, atom()}, {global, term()}, {via, module(), term()}) and is also threaded into log / otel attributes as listener_name.

Stop a running server immediately.

Stop a running server with options. Options

Types

authority()

-type authority() :: nhttp_lib:authority().

conn_spawn_opt()

-type conn_spawn_opt() ::
          {priority, erlang:priority_level()} |
          {fullsweep_after, non_neg_integer()} |
          {min_heap_size, non_neg_integer()} |
          {min_bin_vheap_size, non_neg_integer()} |
          {max_heap_size, erlang:max_heap_size()} |
          {message_queue_data, erlang:message_queue_data()} |
          {async_dist, boolean()}.

An erlang:spawn_opt/4 option for the processes that run handler code: the connection processes of HTTP/1.1, HTTP/2 and HTTP/3, and the stream workers. link and monitor are not allowed, because nhttp owns them. Default [].

Under load, old-generation garbage can hold most of the heap of a connection. {fullsweep_after, 0} collects it on every garbage collection. Each collection then shrinks the heap to the live data, and HTTP throughput drops. A min_heap_size that holds the allocations of one request restores the throughput, but every active connection then holds at least that heap.

h2_dispatch()

-type h2_dispatch() :: worker | inline.

Where handle_request/2 runs on HTTP/2. inline: in the connection process, in stream order, as on HTTP/1.1 and HTTP/3. A request with a body runs in a worker. worker: in one worker per stream. Default inline.

h2_response_delay()

-type h2_response_delay() :: non_neg_integer() | {uniform, non_neg_integer(), non_neg_integer()}.

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.

h2_window_policy()

-type h2_window_policy() ::
          eager | {threshold, pos_integer()} | {delay, non_neg_integer()} | on_response | never.

Credit policy for an HTTP/2 receive window. eager sends a WINDOWUPDATE 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 windowh2_credit_batchholds that credit until the responded octets reach the batch, then sends one WINDOW_UPDATE of exactly the batch.neversends no credit. Defaulteager`.

header_name()

-type header_name() :: nhttp_lib:header_name().

header_value()

-type header_value() :: nhttp_lib:header_value().

headers()

-type headers() :: nhttp_lib:headers().

method()

-type method() :: nhttp_lib:method().

name()

-type name() :: nhttp_listener:name().

opts()

-type opts() ::
          #{acceptor_count => pos_integer(),
            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_dispatch => h2_dispatch(),
            h2_initial_window_size => 1..2147483647,
            h2_max_frame_size => 16384..16777215,
            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(),
            compression_level => 1..9,
            compression_threshold => non_neg_integer(),
            conn_spawn_opts => [conn_spawn_opt()],
            max_connections => pos_integer() | infinity,
            max_header_name_length => pos_integer(),
            max_header_value_length => pos_integer(),
            max_header_size => pos_integer(),
            max_headers => pos_integer(),
            max_uri_length => pos_integer(),
            max_body_size => pos_integer(),
            nodelay => boolean(),
            port := inet:port_number(),
            versions => [http1_1 | http2 | http3],
            timeouts => timeouts(),
            tls => tls(),
            proxy_protocol => boolean() | proxy_protocol_opts(),
            quic => quic_opts()}.

peer()

-type peer() :: nhttp_lib:peer().

proxy_protocol_opts()

-type proxy_protocol_opts() :: #{version => v1 | v2 | both, timeout => timeout()}.

quic_error()

-type quic_error() :: term().

quic_opts()

-type quic_opts() ::
          #{receivers => pos_integer(),
            ip => inet:ip_address(),
            ipv6_v6only => boolean(),
            transport => nquic:transport_opts(),
            cc => nquic:cc_opts()}.

Settings of the nquic listener that serves http3. nhttp passes them to nquic:listen/2, which checks the values.

  • receivers: the number of UDP sockets on the QUIC port, bound with SO_REUSEPORT. Each socket has one receiver process, and the kernel spreads the peers over the sockets. Default: the number of online schedulers.
  • ip: the local address of the QUIC sockets. Default {0,0,0,0}. An IPv6 address opens IPv6 sockets. {0,0,0,0,0,0,0,0} with ipv6_v6only => false also accepts IPv4 peers. A host where IPv6 is off cannot bind an IPv6 address.
  • ipv6_v6only: the IPV6_V6ONLY socket option for an IPv6 ip.
  • transport: gso, pacing and the other send settings of nquic:transport_opts/0.
  • cc: the congestion controller, nquic:cc_opts/0.

request()

-type request() :: nhttp_lib:request().

response()

-type response() :: nhttp_lib:response().

scheme()

-type scheme() :: nhttp_lib:scheme().

start_error()

-type start_error() ::
          {invalid_opts, {nhttp_error:category(), nhttp_error:reason()}} |
          {listen_failed, inet:posix() | quic_error()} |
          {already_started, pid()}.

status()

-type status() :: nhttp_lib:status().

stop_opts()

-type stop_opts() ::
          #{drain => boolean(), drain_timeout => timeout(), force_kill_delay => non_neg_integer()}.

stream_id()

-type stream_id() :: nhttp_lib:stream_id().

timeouts()

-type timeouts() ::
          #{idle => timeout(),
            request => timeout(),
            body => timeout(),
            body_deadline => timeout(),
            send => timeout(),
            hibernate => timeout()}.

Connection timeouts (milliseconds, or infinity).

  • idle: keep-alive gap between requests; re-arms per packet. Default 60000.
  • request: absolute deadline from the first byte of a request head to a complete head (HTTP/1.1). Caps slowloris drip regardless of idle. On a cleartext listener with http1_1 and http2, also the deadline from the accept to the bytes that choose the protocol. Default 30000.
  • body: per-chunk idle while receiving a request body; re-arms per chunk. Default 60000.
  • body_deadline: absolute deadline from head-complete to a fully received body (HTTP/1.1). Default infinity (opt-in) so legitimate large or slow uploads are not aborted.
  • send: socket send timeout. Default 30000.
  • hibernate: quiet time after which a connection with nothing in flight hibernates. The wait consumes part of the idle budget, so the idle timeout still fires on time. 0 hibernates on every quiet moment, infinity never. Default 1000.

tls()

-type tls() ::
          #{certfile := file:filename(),
            keyfile := file:filename(),
            cacertfile => file:filename(),
            verify => verify_none | verify_peer,
            sni_fun => fun((string()) -> [ssl:tls_server_option()] | undefined),
            sni_hosts => [{string(), [ssl:tls_server_option()]}],
            extra => [ssl:tls_server_option()]}.

version()

-type version() :: nhttp_lib:version().

Functions

active_connections()

-spec active_connections() -> non_neg_integer().

Best-effort count of in-flight connections across every nhttp listener in this VM.

Backed by an atomics counter incremented on connection accept and decremented on connection terminate. A brutal kill that bypasses terminate/2 leaks the slot. The counter never goes below zero because the reader floors at 0.

active_requests()

-spec active_requests() -> non_neg_integer().

Count of in-flight requests across every nhttp listener in this VM. Backed by nhttp_otel's active-requests metric (always on for queries even when otel export is disabled).

active_ws_sessions()

-spec active_ws_sessions() -> non_neg_integer().

Best-effort count of live WebSocket sessions across every nhttp listener in this VM. Incremented after the upgrade response is on the wire, decremented when the session terminates. Same brutal-kill caveat as active_connections/0.

child_spec(Opts)

-spec child_spec(opts()) -> supervisor:child_spec().

Generate a child spec for embedding the listener in your supervisor with an auto-generated id. Use this form when you do not need to look the listener up by name later.

child_spec(Name, Opts)

-spec child_spec(name(), opts()) -> supervisor:child_spec().

Generate a child spec for embedding the listener in your supervisor under a caller-supplied name. The supervisor child id is {nhttp_listener, Name} and the listener registers itself under Name when it starts.

drain(Pid)

-spec drain(pid()) -> ok.

Drain connections with the default 10-second timeout. Stops the acceptor sub-supervisor, signals every live connection to wind down, and blocks until they exit or the timeout elapses. The listener itself stays up. Call stop/1 afterwards to tear it down.

drain(Pid, Timeout)

-spec drain(pid(), timeout()) -> ok.

Drain connections with a custom timeout (milliseconds or infinity). See drain/1 for the behaviour. The listener itself stays up.

get_port(Pid)

-spec get_port(pid()) -> {ok, inet:port_number()} | {error, term()}.

Get the TCP port a server is listening on. Returns the TCP/TLS transport's port. For a QUIC-only server it returns the sole transport's UDP port. Useful when port 0 was specified to get an ephemeral port. A mixed [http1_1, http2, http3] server binds two transports: TCP/TLS for h1/h2 and UDP/QUIC for h3. With port => 0 they bind different ephemeral ports. Use get_port/2 or get_ports/1 to read the UDP port.

get_port(Pid, Kind)

-spec get_port(pid(), tcp | quic) -> {ok, inet:port_number()} | {error, term()}.

Get the bound port for a specific transport (tcp or quic). tcp covers the h1/h2 listener, quic the h3 listener. Returns {error, {server, {no_transport, Kind}}} when the server does not serve that transport.

get_ports(Pid)

-spec get_ports(pid()) -> #{tcp | quic => inet:port_number()}.

Get every bound transport port as a map keyed by transport. A single-transport server yields a one-entry map. A mixed server yields #{tcp => P1, quic => P2}. With port => 0 the two ports differ.

header(Name, Request)

-spec header(header_name(), request()) -> header_value() | undefined.

Look up a request header by name (case-insensitive). Top-level convenience wrapper over nhttp_req:header/2. Returns undefined when the header is missing.

header(Name, Request, Default)

-spec header(header_name(), request(), Default) -> header_value() | Default.

Look up a request header by name (case-insensitive), with a default. Top-level convenience wrapper over nhttp_req:header/3.

start_link(Opts)

-spec start_link(opts()) -> {ok, pid()} | ignore | {error, start_error()}.

Start an unnamed HTTP server linked to the calling process. Returns the supervisor pid. For production, use nhttp_listener:child_spec/1,2 to embed in your supervision tree instead. OpenTelemetry can be enabled via #{otel => true} or #{otel => #{traces => true, metrics => true}}.

start_link(Name, Opts)

-spec start_link(name(), opts()) -> {ok, pid()} | ignore | {error, start_error()}.

Start a named HTTP server linked to the calling process. Name follows the standard gen_server registration shapes (atom(), {local, atom()}, {global, term()}, {via, module(), term()}) and is also threaded into log / otel attributes as listener_name.

stop(Pid)

-spec stop(pid()) -> ok.

Stop a running server immediately.

stop(Pid, Opts)

-spec stop(pid(), stop_opts()) -> ok | {error, {invalid_stop_opts, atom()}}.

Stop a running server with options. Options:

  • drain => false (default): Stop immediately. drain_timeout is rejected in this mode to surface configuration mistakes.
  • drain => true: Drain connections, then stop. Defaults to a 10 second drain_timeout if none is supplied.
  • drain_timeout => timeout(): Override the drain deadline (ms or infinity). Only meaningful when drain => true. During drain:
  1. Stop accepting new connections
  2. Signal existing connections to complete and close
  3. Wait for connections to drain (up to drain_timeout)
  4. Force-close remaining connections after the timeout elapses