nhttpc (nhttpc v1.0.0)
View SourceClient public API for nhttpc.
HTTP/1.1, HTTP/2, HTTP/3 and WebSocket requests share one opts map,
one async envelope and one error taxonomy. Request and response maps,
header lists and version atoms are the nhttp_lib types:
nhttp_lib:request/0, nhttp_lib:response/0, nhttp_lib:version/0.
Quick start
%% One-shot GET
{ok, #{status := 200, body := Body}} =
nhttpc:get(<<"https://api.example.com/users/42">>).
%% POST with headers
{ok, Resp} = nhttpc:post(
<<"https://api.example.com/users">>,
<<"{\"name\":\"alice\"}">>,
#{headers => [{<<"content-type">>, <<"application/json">>}]}).
%% Build the canonical request manually
{ok, Req} = nhttpc:url(get, <<"https://api.example.com/users/42">>),
{ok, Resp} = nhttpc:request(Req#{headers => [{<<"accept">>, <<"application/json">>}]}).Responses are plain maps:
#{status := 200, headers := Headers, body := Body} = Resp.Request options
The options of nhttpc:opts/0 that the sections below do not cover:
| Option | Default | Purpose |
|---|---|---|
headers | [] | Extra request headers |
compress | true | Send accept-encoding: gzip, deflate unless the request sets it |
decompress | true | Decode a gzip or deflate response body |
max_response_size | 100 MiB | Larger bodies fail with {stream_error, exceeded_max_response_size} |
max_header_size | 64 KiB | Bound on the response header block |
max_headers_count | 100 | Bound on the number of response headers |
on_download_progress | none | fun(Received, Total) -> ok called during a body_to download. Total is unknown without a content-length |
timeouts | see Pool options | #{connect, request, checkout, idle} |
Dispatch modes (via)
A single tagged value selects how the request gets to the wire. The
default is oneshot; pass via => … to change it.
%% 1. one-shot (default): connect → send → recv → close
{ok, Resp} = nhttpc:get(Url).
%% 2. pooled: borrow a persistent connection
{ok, Resp} = nhttpc:get(Url, #{via => {pool, api_pool}}).
%% 3. on-connection: caller owns the multiplexed transport
{ok, Conn} = nhttpc:open(#{host => <<"api.example.com">>, port => 443},
#{versions => [http2]}),
{ok, Resp1} = nhttpc:get(<<"https://api.example.com/v1/a">>,
#{via => {conn, Conn}}),
{ok, Resp2} = nhttpc:get(<<"https://api.example.com/v1/b">>,
#{via => {conn, Conn}}),
ok = nhttpc:close(Conn).Illegal combinations like "pool and conn at the same time" are unrepresentable. Dialyzer enforces it.
Connection pools
%% Start (usually under your supervisor)
{ok, _} = nhttpc:start_link(api_pool, #{
host => <<"api.example.com">>,
port => 443,
size => 4,
versions => [http2, http1_1],
tls => #{verify => verify_peer},
timeouts => #{connect => 5_000, request => 30_000}
}).
%% Use the pool
{ok, Resp} = nhttpc:get(<<"https://api.example.com/users">>,
#{via => {pool, api_pool}}).
%% Stop
ok = nhttpc:stop(api_pool).
%% or with draining
ok = nhttpc:stop(api_pool, #{drain => true, drain_timeout => 5_000}).The pool ref accepts the registered atom, the supervisor pid, or the
manager pid uniformly. One pool serves one host:port target; spin
up a pool per backend.
Pool options
| Option | Default | Purpose |
|---|---|---|
host / port | required | Target |
size | 4 | Persistent connections. Under pack this is an upper bound, not a target |
stream_selection | pack | pack starts one multiplexed connection and adds more only when no connection has a free stream slot and the send credit for the body. spread opens size connections at start |
max_overflow | 4 | Extra connections under load. See the sizing rule below |
versions | [http2, http1_1] | Allowed protocols, advertised via ALPN |
tls | #{verify => verify_peer} if scheme = https | TLS map (see below) |
timeouts.connect | 5 000 ms | TCP / TLS handshake budget |
timeouts.checkout | 5 000 ms | Wait for a free connection |
timeouts.request | 30 000 ms | End-to-end request budget |
timeouts.idle | 60 000 ms | Idle connection eviction |
idle_timeout | timeouts.idle | Same as timeouts.idle, and takes precedence over it |
max_connection_age | infinity | Age in ms after which a connection with no live stream is closed |
connection_sweep_interval | 60 000 ms | How often the pool checks max_connection_age |
max_pending_requests | 1000 | Checkouts that may wait at the manager. Past it a request gets {error, pool_full} |
reconnect_backoff_base / reconnect_backoff_max | 100 / 30 000 ms | Bounds of the delay before reopening a connection that keeps failing |
max_streams_per_connection | 2^29 | Streams an HTTP/2 connection opens before it retires. At most 2^30 - 1. See the rotation note below |
max_send_buffer | 128 KiB | Octets of request bodies one HTTP/2 connection holds in its send queue, waiting for the peer's credit. See the large body note below |
max_queued_streams | peer cap / 4 | Streams with octets in the send queue on one HTTP/2 connection |
proxy | none | HTTP CONNECT proxy map |
A multiplexed connection takes a request only when it has a free stream
slot and the HTTP/2 send credit for the body. The pool grows on
whichever runs short first, up to size + max_overflow. An origin that
credits the connection window on processing bounds each connection to
half_window / credit_latency octets a second. Size the pool from
offered_rate * body_size * credit_latency / half_window, multiplied
by two for headroom, where half_window is the credit the peer returns
at a time and credit_latency is its response latency. The
[nhttpc, connection, credit] event reports both inputs: the peer's
credit rate and the interval between its WINDOW_UPDATE frames. APNs
returns 32830 octets at a time, 270 milliseconds after the body at the
median, so 10000 requests a second of 134-octet bodies need 11
connections at zero headroom: size => 22, max_overflow => 11. The
defaults fit an origin that credits on receipt.
A pool sized above that product keeps every checkout on the lock-free path. A pool sized below it serves at the ceiling of its connections and queues the rest at the manager, which then sees every checkout and every checkin as a message.
An HTTP/2 connection has 2^30 stream identifiers. A connection that
opens max_streams_per_connection streams tells the pool it is
draining, serves what is in flight, and exits. The pool opens the
replacement when the drain starts, for a rotation and for a peer
GOAWAY alike, so the pool is never a connection short while the old one
finishes. The default of 2^29 gives 4.3 days for each connection at
1430 streams a second. Each rotation pays a TLS handshake and a cold
HPACK table. size + max_overflow bounds the connections that take
requests. The live process count is that bound plus the connections
draining at the moment, which the request timeout bounds. The
[nhttpc, connection, rotated] event fires when a drain starts.
Send-side flow control
An HTTP/2 peer bounds what the client can send with two windows: one for the connection and one for each stream, 65535 octets each at the start. The peer grows them with WINDOW_UPDATE frames on its own schedule. APNs credits the connection window on processing, 32830 octets at a time, about 270 milliseconds after the body, and never credits a stream window.
The client admits a body only against credit it holds. Checkout reserves the body size from the connection's credit slot with one atomic operation, next to the stream slot. A connection whose credit is short takes no body, so the connection never writes a frame the peer cannot receive, never blocks on the socket, and keeps reading the peer while its callers wait. The credit returns when the peer's WINDOW_UPDATE arrives, and the connection publishes it to the pool at once.
What a caller sees at the bound:
- When no connection has the credit, the pool opens one, up to
size + max_overflow. The[nhttpc, pool, credit_starved]event fires, rate-limited to one a second, when a checkout finds every connection short of credit. That event is the sizing signal. - Past the bound the request waits for credit as a pool waiter and gets
{timeout, checkout}at the checkout deadline. Nothing was sent, so the request is safe to retry. The[nhttpc, pool, occupancy]event reports the waiters and the connections as a level, once a second while a waiter is queued. - A body that fits a fresh stream window, and that reaches a connection
whose credit an earlier batch spent, is refused before a stream opens.
The pool re-dispatches it up to two more times on another connection
and then answers
{stream_error, {protocol, refused_stream}}. The peer never saw the request. The[nhttpc, connection, send_credit_refused]event counts those refusals. - A body above the initial window, 65535 octets, or above the stream
window the peer advertises, opens its stream and sends what the
windows take. The rest waits in the connection's send queue and
leaves as the peer credits.
max_send_bufferbounds the octets one connection holds this way, 128 KiB by default, andmax_queued_streamsthe streams, a quarter of the peer's stream cap by default. A body that meets either bound is refused and re-dispatched like a body without credit. A request with octets still queued at its deadline gets the retryable refusal too: its END_STREAM never left, so the peer never processed it. While the queued octets leave, every grant from the peer pays for them, so the connection takes no other body until its queue is empty. A caller with a body waits at the manager or goes to another connection. A pool whose every connection holds a queued body against a peer that credits nothing answers each caller with{timeout, checkout}at its deadline and opens no connection. The[nhttpc, connection, send_blocked]and[nhttpc, connection, send_resumed]events mark the queue going non-empty and empty, and[nhttpc, connection, send_queue]samples its occupancy once a second while it holds octets. - A GOAWAY refuses the streams above its last stream id with
{protocol_error, http2, {h2, Code, DebugData}}. The peer never processed them. The streams at or below it complete on the draining connection.
Size the pool from the bandwidth-delay product of the peer's credit, as described under Pool options.
TLS
tls => Map is tls_opts/0, the client side of the nhttp server
TLS map. Verify-peer is the default; system CAs come from
public_key:cacerts_get/0.
%% Custom CA file
#{tls => #{cacertfile => <<"/etc/ssl/internal-ca.crt">>}}
%% Pinned CA list
#{tls => #{cacerts => Derlist}}
%% Disabled verification (avoid in production)
#{tls => #{verify => verify_none}}
%% Versions, ALPN, SNI
#{tls => #{versions => ['tlsv1.2', 'tlsv1.3'],
alpn_advertised_protocols => [<<"h2">>, <<"http/1.1">>],
server_name_indication => "api.example.com"}}
%% Key exchange groups, in order of preference
#{tls => #{supported_groups => [x25519, secp256r1]}}There is no transport option. The wire transport is derived from
URL scheme + presence of tls + chosen versions:
| URL scheme | versions | Wire transport |
|---|---|---|
http | h1 / h2 (h2c) | plaintext TCP |
https | h1 / h2 | TLS over TCP |
https | h3 | QUIC / UDP |
HTTP/3
%% Poolless
{ok, Resp} = nhttpc:get(<<"https://example.com/path">>,
#{versions => [http3]}).
%% Pooled
{ok, _} = nhttpc:start_link(h3_pool, #{
host => <<"api.example.com">>,
port => 443,
versions => [http3]
}),
{ok, Resp} = nhttpc:get(<<"https://api.example.com/v1/users">>,
#{via => {pool, h3_pool}}).nquic is in nhttpc.app.src's applications list, so H3 is
available from a release boot.
WebSocket
WebSocket folds into the unified async protocol:
{ok, H} = nhttpc:ws_open(<<"wss://example.com/socket">>, #{}),
ok = nhttpc:ws_send(H, {text, <<"hello">>}),
receive
{nhttpc, H, {ws_ready, _Info}} -> ok;
{nhttpc, H, {ws, {text, Msg}}} -> handle_text(Msg);
{nhttpc, H, {ws, {binary, Bin}}} -> handle_bin(Bin);
{nhttpc, H, {done, {remote_closed, Code}}} -> server_closed(Code);
{nhttpc, H, {done, normal}} -> ok
end,
ok = nhttpc:cancel(H).WS-only options ride under ws => …:
nhttpc:ws_open(Url, #{
ws => #{subprotocols => [<<"chat">>],
ping_interval => 30_000}
}).Async requests
{ok, H} = nhttpc:send(Req, #{}),
{ok, Resp} = nhttpc:await(H, 5_000),
%% or cancel without waiting:
ok = nhttpc:cancel(H).Cancellation drains the owner's mailbox of {nhttpc, H, _} before
returning, so no events leak.
Streaming downloads
For sync calls, body_to discriminates the sink:
%% Buffer to file
{ok, #{body := {file, Path, BytesWritten}}} =
nhttpc:get(Url, #{body_to => {file, "/tmp/big.bin"}}).
%% Fold each chunk
{ok, #{body := {fold, Sum}}} =
nhttpc:get(Url, #{body_to => {fold, fun(Chunk, Acc) ->
{continue, Acc + byte_size(Chunk)}
end},
fold_init => 0}).For event-driven streaming use stream/2 and pace the producer with
consume/2:
{ok, H} = nhttpc:stream(Req, #{consume_initial => 64 * 1024,
consume_low_water => 16 * 1024}),
receive {nhttpc, H, {headers, _S, _Hs}} -> ok end,
loop(H, 0).
loop(H, Total) ->
receive
{nhttpc, H, {data, Chunk, more}} ->
handle_chunk(Chunk),
ok = nhttpc:consume(H, byte_size(Chunk)),
loop(H, Total + byte_size(Chunk));
{nhttpc, H, {data, Chunk, fin}} ->
handle_chunk(Chunk),
{ok, Total + byte_size(Chunk)};
{nhttpc, H, {pause, _Buffered}} -> loop(H, Total);
{nhttpc, H, {resume, _Buffered}} -> loop(H, Total);
{nhttpc, H, {done, normal}} -> {ok, Total};
{nhttpc, H, {error, R}} -> {error, R}
end.Streaming uploads
%% From a file
{ok, Resp} = nhttpc:post(Url, <<>>,
#{body_from => {file, "/path/to/big.bin"}}).
%% From a custom iterator
Iter = fun() ->
case ... of
eof -> fin;
Chunk -> {ok, Chunk, Iter}
end
end,
{ok, Resp} = nhttpc:post(Url, <<>>, #{body_from => {iter, Iter}}).
%% Progress
nhttpc:post(Url, <<>>,
#{body_from => {file, Path},
on_upload_progress => fun(Sent, _Total) ->
io:format("uploaded ~p~n", [Sent])
end}).
%% Read size of a file source, 65536 octets by default
nhttpc:post(Url, {stream, {file, Path}, #{chunk_size => 262144}}, #{}).A file source is read chunk_size octets at a time. On HTTP/2 the
codec splits every chunk at the peer's frame size and holds what the
peer's windows cannot take yet, at most 128 KiB, so chunk_size is a
read size and not a frame size. The uploader reads the socket while it
waits for credit, and on_upload_progress reports the octets written
to the socket. An upload that reaches its request timeout with octets
still held sends RST_STREAM and answers
{stream_error, {protocol, refused_stream}}.
Cookie jar
{ok, Jar} = nhttpc_cookie_jar:start_link(),
{ok, _} = nhttpc:get(<<"https://example.com/login">>,
#{cookie_jar => Jar}),
{ok, _} = nhttpc:get(<<"https://example.com/dashboard">>,
#{cookie_jar => Jar}),
ok = nhttpc_cookie_jar:stop(Jar).jar() is an opaque reference(). The pid + ETS table are held under
persistent_term keyed on the ref; the cookie API is the only thing
the caller ever touches.
Retries
%% Up to 3 attempts (retries are off by default)
nhttpc:get(Url, #{retry => 3}).
%% Full control
nhttpc:get(Url, #{retry => #{
max_attempts => 5,
base_delay => 100,
max_delay => 5_000,
backoff => exponential, %% exponential | linear | constant
jitter => true,
retry_on => [timeout, connection_error, 503, 429]
}}).Without retry no request is retried. Once enabled, the default
condition set depends on the method. An idempotent method retries on
timeout, connection_error, connection_refused, connection_reset,
and 502/503/504. A POST or a PATCH retries on connection_refused
alone, the one condition that proves the request never reached the
server.
retry_on => [auto] delegates the decision to the protocol layer,
subject to the same method gate. For an idempotent method auto retries
a closed connection, a GOAWAY, and a refused stream. For a non-idempotent
method auto shrinks to connection_refused. Status codes listed next to
auto are explicit and apply to every method. A caller that wants
at-least-once delivery on a POST must list the conditions or supply a
should_retry callback.
Redirects
follow_redirects => true (default) follows 301/302/303/307/308. The
max_redirects cap defaults to 5. redirect_policy => fun/4 lets you
inspect each hop and either continue, stop, or supply updated opts.
Cross-origin hops strip Authorization, Cookie, and other
credential-bearing headers by default.
Error taxonomy
-type nhttpc:error() ::
{invalid_request, atom()}
| {invalid_opts, atom()}
| {connect_error, inet:posix() | term()} %% term() only for QUIC
| {tls_error, tls_reason()}
| {timeout, connect | request | checkout | idle | body}
| {protocol_error, nhttp_lib:version(), protocol_detail()}
| {http_error, nhttp_lib:status(), nhttp_lib:headers()}
| {redirect_error, redirect_reason()}
| {retries_exhausted, pos_integer(), nhttpc:error()}
| {stream_error, stream_reason()}
| {body_error, body_reason()}
| {cookie_jar_error, cookie_jar_reason()}
| {file_error, file:posix() | badarg | terminated | system_limit}
| pool_full
| pool_closed.Every public spec returns {error, nhttpc:error()}; no upstream
term() leaks except the documented connect_error arm.
An HTTP/2 GOAWAY reaches the caller as {protocol_error, http2, {h2, Code}}. When the frame carries debug data, the detail is {h2, Code, DebugData} with the raw binary from the frame. APNs puts a JSON
reason there. The client does not parse it.
Telemetry
Instrumentation is opt-in through the otel request / pool option and
is off by default. When it is unset the request path reads one map key
and takes an uninstrumented branch: no clock read, no metadata map, no
pool lookup, no telemetry:execute/3.
otel => #{events => true, metrics => true, traces => true}
otel => true %% shorthand for all three
otel => false %% defaultThe map form enables only the flags it lists: #{metrics => true}
records instruments but publishes no telemetry events. traces is
accepted and reserved; no spans are emitted yet.
Events
Published when events is enabled. Request events also carry
method, url, scheme, host, port, path and pool.
| Event | Measurements | Extra metadata |
|---|---|---|
[nhttpc, request, start] | monotonic_time | none |
[nhttpc, request, stop] | duration (µs), req_body_length, resp_body_length | status |
[nhttpc, request, error] | duration (µs), req_body_length, resp_body_length | reason |
[nhttpc, connect, start] | monotonic_time | host, port, transport |
[nhttpc, connect, stop] | duration (µs) | host, port, protocol |
[nhttpc, connect, error] | duration (µs) | host, port, reason |
[nhttpc, pool, checkout] | queue_time (ms) | pool |
[nhttpc, pool, checkin] | none | pool |
[nhttpc, pool, timeout] | none | pool |
[nhttpc, pool, exhausted] | none | pool |
[nhttpc, connection, idle_timeout] | none | host, port |
[nhttpc, connection, send_credit_refused] | send_window | host, port, conn_pid, stream_id |
[nhttpc, connection, credit] | count, octets, mean_interval (µs), max_interval (µs) | host, port, conn_pid |
[nhttpc, connection, goaway] | none | host, port, conn_pid, error_code, last_stream_id, debug_data |
[nhttpc, connection, rotated] | streams | host, port, conn_pid |
[nhttpc, pool, credit_starved] | none | pool |
[nhttpc, connection, send_blocked] | send_window, send_buffer_bytes, queued_streams, send_blocked_ms (ms) | host, port, conn_pid |
[nhttpc, connection, send_resumed] | the four send queue gauges, blocked_ms (ms) | host, port, conn_pid |
[nhttpc, connection, send_queue] | the four send queue gauges | host, port, conn_pid |
[nhttpc, connection, max_streams_changed] | previous_max_streams, max_streams | host, port, conn_pid |
[nhttpc, pool, occupancy] | waiter_count, connections | pool |
The body length measurements are omitted when the size cannot be known up front, as for a streaming source, rather than reported as zero.
The queue_time of the [nhttpc, pool, checkout] event is in
milliseconds. The nhttpc.http.pool.queue_time metric records the same
wait in microseconds.
The version atom in events is nhttp_lib:version/0 (http1_1 | http2 | http3).
The send queue gauges describe one HTTP/2 connection. send_window is
the connection send window of the codec, send_buffer_bytes the octets
the send queue holds, queued_streams the streams with an entry in it,
and send_blocked_ms the time the connection held a non-empty send
queue since it connected. send_blocked_ms accumulates. It counts the
period that is open when the event fires, and it never resets, so a
collector takes the rate from it.
send_blocked fires when the send queue goes from empty to non-empty
and send_resumed when it empties, with blocked_ms the length of the
period that ended. The pair marks transitions. It does not bracket every
starvation period: against a peer that credits every few hundred
milliseconds, one large upload keeps the queue non-empty for its whole
life, and the pair degenerates to one send_blocked event and a
growing send_blocked_ms. send_queue is the sample for that case. It
fires once a second at most while the queue is non-empty, and once with
every credit report. The events are about the codec send queue. The
credit_starved and send_credit_refused events are about pool
admission, which keeps a small body off the queue, so a unary workload
sees those two and not these.
max_streams_changed fires when a SETTINGS frame moves the concurrent
stream cap of a connection, with the cap before and after the frame. A
frame that repeats the cap fires nothing.
occupancy reports the level of a pool. waiter_count is the checkouts
parked at the manager for a connection with a free stream slot and the
send credit for the body. connections is the connections that take
requests, ready or connecting, which size + max_overflow bounds. A
draining connection is not counted. The manager samples the level once a
second at most while it holds a waiter, and once more when the last
waiter leaves, so the level returns to zero. A pool that never queues a
waiter emits nothing. A pool at the bound with a non-zero waiter_count
is saturated.
Metrics
Recorded when metrics is enabled, against the nhttpc meter.
Attributes follow the OpenTelemetry HTTP client semantic conventions.
| Instrument | Kind | Unit |
|---|---|---|
nhttpc.http.request.duration | histogram | µs |
nhttpc.http.connect.duration | histogram | µs |
nhttpc.http.pool.queue_time | histogram | µs |
nhttpc.http.request.body.size | histogram | bytes |
nhttpc.http.response.body.size | histogram | bytes |
nhttpc.http.active_requests | updown counter | 1 |
nhttpc.http.pool.exhausted | counter | 1 |
nhttpc.http.pool.timeout | counter | 1 |
Coverage
request/2 and send/2 are instrumented. stream/2 is instrumented
on HTTP/3 only; the HTTP/1.1 and HTTP/2 streaming path goes through
nhttpc_stream_recv and does not emit request events yet.
Production notes
- One pool per
host:port. A pool withhost => <<"a">>will not serve requests targeted at<<"b">>even if the URL says otherwise. - Pools are long-lived. The
persistent_termlookup pays a global scan cost once at start_link; rapid pool churn defeats the optimisation. - A cookie jar holds a
persistent_termentry too, and stopping it pays the same global cost. Start a jar per session or per user, not per request. verify_nonedisables certificate validation. Use it only for development; production should keepverify_peerand supply a pinned CA if the default trust store isn't sufficient.- Idle connections are evicted by
timeouts.idle. Servers that sendConnection: closemid-pool cause one extra reconnect on the next checkout. Servers that sendGOAWAY(H2) trigger graceful drain, and the pool opens the replacement connection when the drain starts. - HTTP/3 is over connected UDP. Networks that drop UDP between
reconnects (corporate proxies, some load balancers) require an H2
fallback in
versions => [http3, http2, http1_1].
Supervision
init([]) ->
Pool = nhttpc:child_spec(api_pool, #{
host => <<"api.example.com">>,
port => 443,
size => 8,
tls => #{verify => verify_peer}
}),
{ok, {#{strategy => one_for_one, intensity => 5, period => 10},
[Pool]}}.
Summary
Functions
Wait for async response with default timeout.
Wait for async response with timeout.
Await response from a specific connection with proper monitor.
Handles connection death gracefully. Use this with send_request/2.
Cancel an async request.
The call blocks until the worker has torn down any in-flight request
and acknowledged, then drains the caller's mailbox of
{nhttpc, Handle, _} so the no-leak contract holds. Returns ok.
Return a child spec for embedding in supervision trees.
Close a connection opened with open/2. Idempotent — calling close on
an already-closed connection is a no-op.
Grant N additional bytes of consumer credit to a streaming handle.
Streaming downloads use explicit consumer-credit backpressure: the
worker delivers {nhttpc, Handle, {data, _, _}} events only while the
caller has uncomsumed credit. When credit is exhausted, the worker
holds the next chunk and emits {nhttpc, Handle, {pause, BytesBuffered}};
after consume/2 drops the buffer below consume_low_water, a
{resume, BytesBuffered} event is emitted.
consume/2 is a no-op for non-streaming handles (send/2, ws_open/2),
for already-finished streams, and for streams started with
consume_initial => infinity.
DELETE request.
DELETE request with options.
Drain any pending {nhttpc, Handle, _} messages from the caller's mailbox.
Idempotent and never blocks. Useful after a unified-protocol request
already completed to discard the lingering {done, _} event.
Simple GET request.
GET request with options.
HEAD request.
HEAD request with options.
Open a standalone connection to Target, returning an opaque
connection() the caller passes via via => {conn, _} on subsequent
requests.
Useful primarily for HTTP/2 and HTTP/3 multiplexing where one
transport carries many concurrent requests. The handshake (TCP + TLS,
or QUIC) completes synchronously; a failure surfaces as
{error, {connect_error, _}}.
OPTIONS request.
OPTIONS request with options.
Parse a URL into components.
Supports the http, https, ws, and wss schemes.
PATCH request with body.
PATCH request with body and options.
POST request with body.
POST request with body and options.
PUT request with body.
PUT request with body and options.
Send a request and wait for the response.
Send a request with options.
Send an async request returning a unified handle.
The handle delivers events as {nhttpc, Handle, Event} messages to the
caller. The terminal event is {done, normal} after {response, _} or
{error, _} on failure.
Send an async request with options. Returns a unified handle.
Send request asynchronously on a specific connection.
This is for users who manage connections directly (e.g., via pools).
Returns immediately with a StreamRef. Response delivered via message:
{nhttpc_response, ConnPid, StreamRef, {ok, Response}}
{nhttpc_error, ConnPid, StreamRef, Reason}
Use await_response/3 for convenient selective receive.
Start a client pool linked to the calling process.
Stop a client pool immediately.
Stop a client pool with options.
Mirrors nhttp:stop/2 (server side). Accepted options
Open a streaming request, returning a unified handle().
Per-chunk events are delivered as {nhttpc, Handle, Event} messages on
the caller's mailbox
Open a streaming request with options. See stream/1.
Build a canonical nhttp_lib:request() from a method and URL.
Parses the URL and fills method, scheme, authority, path, and
an empty headers list on the result. Callers merge in their own
headers and body before dispatching
Open a WebSocket connection, returning a handle().
The owner receives events through the {nhttpc, Handle, _} envelope
Send a WebSocket frame on a handle returned by ws_open/2.
Frame is an nhttp_lib:ws_frame() — {text, _}, {binary, _},
{ping, _}, or {pong, _}. Returns {error, {invalid_request, _}}
if the handle was not opened for WebSocket, and
{error, {ws_closed, noproc}} if the WS process has already exited.
Types
-type backoff_strategy() :: exponential | linear | constant.
-type body_reason() :: {iterator, term()} | {file_read, file_reason()} | {file_open, file_reason()} | size_mismatch.
-opaque connection()
-type cookie_jar_reason() :: stopped | persist_timeout | {file, file:posix()}.
-type done_reason() :: normal | cancelled | {remote_closed, nhttp_lib:error_code() | undefined}.
-type error() :: {invalid_request, atom()} | {invalid_opts, atom()} | {connect_error, inet:posix() | term()} | {tls_error, tls_reason()} | {timeout, connect | request | checkout | idle | body} | {protocol_error, nhttp_lib:version(), protocol_detail()} | {http_error, nhttp_lib:status(), nhttp_lib:headers()} | {redirect_error, redirect_reason()} | {retries_exhausted, pos_integer(), error()} | {stream_error, stream_reason()} | {body_error, body_reason()} | {cookie_jar_error, cookie_jar_reason()} | {file_error, file_reason()} | pool_full | pool_closed.
-type event() :: {response, nhttp_lib:response()} | {headers, nhttp_lib:status(), nhttp_lib:headers()} | {data, binary(), nhttp_lib:fin()} | {trailers, nhttp_lib:headers()} | {pause, non_neg_integer()} | {resume, non_neg_integer()} | {ws, nhttp_lib:ws_frame()} | {ws_ready, map()} | {done, done_reason()} | {error, error()}.
-type file_reason() :: file:posix() | badarg | terminated | system_limit.
-type handle() :: nhttpc_handle:handle().
-type handle_kind() :: nhttpc_handle:kind().
-type iterator() :: fun(() -> {ok, iodata(), iterator()} | fin | {error, body_reason()}).
-type opts() :: #{timeouts => timeouts(), tls => tls_opts(), versions => [nhttp_lib:version()], headers => nhttp_lib:headers(), compress => boolean(), decompress => boolean(), cookie_jar => nhttpc_cookie_jar:jar(), follow_redirects => boolean(), max_redirects => non_neg_integer(), redirect_policy => redirect_policy(), retry => false | pos_integer() | retry_opts(), on_upload_progress => progress_fun(), on_download_progress => progress_fun(), max_response_size => pos_integer() | infinity, max_header_size => pos_integer() | infinity, max_headers_count => pos_integer() | infinity, proxy => proxy_opts(), body_to => {file, file:filename()} | {fold, on_data_fun()}, body_from => {file, file:filename()} | {iter, iterator()}, fold_init => term(), consume_initial => pos_integer() | infinity, consume_low_water => pos_integer(), via => via(), ws => map(), otel => map()}.
-type pool_opts() :: #{host := binary() | string() | inet:ip_address(), port := inet:port_number(), timeouts => timeouts(), tls => tls_opts(), versions => [nhttp_lib:version()], size => pos_integer(), max_overflow => non_neg_integer(), idle_timeout => timeout(), max_connection_age => timeout(), max_pending_requests => pos_integer(), max_streams_per_connection => pos_integer(), max_send_buffer => pos_integer() | infinity, max_queued_streams => pos_integer() | infinity, connection_sweep_interval => timeout(), stream_selection => stream_selection(), reconnect_backoff_base => pos_integer(), reconnect_backoff_max => pos_integer(), proxy => proxy_opts(), otel => map()}.
Pool configuration.
size means one of two things, and stream_selection decides which. Under
spread the pool opens size connections at start and keeps that
many. Under pack, which is the default, size is a ceiling: the pool
opens one multiplexed connection and adds another only when no
connection has both a free stream slot and the HTTP/2 send credit for
the body, up to size + max_overflow. See nhttpc_opts for the sizing
rule against a peer that credits on processing. An HTTP/1.1 pool always fills to size, because
its checkout is exclusive.
-type progress_fun() :: fun((Sent :: non_neg_integer(), Total :: non_neg_integer() | unknown) -> ok).
-type protocol_detail() :: nhttp_h1:parse_error() | {h2, nhttp_lib:error_code()} | {h2, nhttp_lib:error_code(), binary()} | {h3, nhttp_lib:error_code()} | nhttp_lib:error().
-type proxy_opts() :: #{host := binary() | string() | inet:ip_address(), port := inet:port_number(), username => binary(), password => binary()}.
-type redirect_policy() :: fun((Status :: nhttp_lib:status(), Location :: binary(), Request :: nhttp_lib:request(), Opts :: opts()) -> continue | stop | {continue, opts()}).
-type request() :: nhttp_lib:request() | map().
-type response() :: nhttp_lib:response().
-type retry_condition() :: timeout | connection_error | connection_refused | connection_reset | auto | nhttp_lib:status().
-type retry_opts() :: #{max_attempts := pos_integer(), base_delay => non_neg_integer(), max_delay => non_neg_integer(), backoff => backoff_strategy(), jitter => boolean(), retry_on => [retry_condition()], should_retry => fun((Result :: {ok, nhttp_lib:response()} | {error, error()}, Attempt :: pos_integer(), Opts :: retry_opts()) -> boolean())}.
-type stop_opts() :: #{drain => boolean(), drain_timeout => timeout(), force_kill_delay => non_neg_integer()}.
-type stream_reason() :: cancelled | rst_stream | {flow_control, term()} | {protocol, nhttp_lib:error()} | exceeded_max_response_size.
-type stream_selection() :: pack | spread.
Connection selection policy for HTTP/2 and HTTP/3 pools.
pack walks the pool oldest connection first and takes the first one
with a free stream slot, so traffic concentrates on one warm
connection. The pool opens another connection only when every
connection is at its concurrent stream cap. RFC 9113 section 9.1 and
RFC 9114 section 3.3 both ask a client to do this.
spread distributes requests over the pool: round-robin on the ETS
fast path, least-loaded through the manager.
HTTP/1.1 checkout is exclusive and ignores this option.
-type target() :: #{host := binary() | inet:ip_address(), port := inet:port_number()}.
-type tls_opts() :: #{cacertfile => file:filename(), cacerts => [public_key:der_encoded()], certfile => file:filename(), keyfile => file:filename(), certs_keys => [map()], verify => verify_none | verify_peer, server_name_indication => inet:hostname() | disable, wildcard_hostname => boolean(), versions => [ssl:protocol_version()], alpn_advertised_protocols => [binary()], supported_groups => [ssl:group()], extra => [ssl:tls_client_option()]}.
-type tls_reason() :: {tls_alert, ssl:tls_alert()} | {options, term()} | {handshake, term()} | {hostname_mismatch, binary()}.
-type url_info() :: nhttpc_url:url_info().
-type via() :: oneshot | {pool, pool_ref()} | {conn, connection()}.
Functions
-spec await(handle()) -> {ok, nhttp_lib:response()} | {error, error()}.
Wait for async response with default timeout.
-spec await(handle(), timeout()) -> {ok, nhttp_lib:response()} | {error, error()}.
Wait for async response with timeout.
-spec await_response(pid(), reference(), timeout()) -> {ok, nhttp_lib:response()} | {error, error()}.
Await response from a specific connection with proper monitor.
Handles connection death gracefully. Use this with send_request/2.
-spec cancel(handle()) -> ok.
Cancel an async request.
The call blocks until the worker has torn down any in-flight request
and acknowledged, then drains the caller's mailbox of
{nhttpc, Handle, _} so the no-leak contract holds. Returns ok.
-spec child_spec(term(), pool_opts()) -> supervisor:child_spec().
Return a child spec for embedding in supervision trees.
-spec close(connection()) -> ok.
Close a connection opened with open/2. Idempotent — calling close on
an already-closed connection is a no-op.
-spec consume(handle(), pos_integer()) -> ok.
Grant N additional bytes of consumer credit to a streaming handle.
Streaming downloads use explicit consumer-credit backpressure: the
worker delivers {nhttpc, Handle, {data, _, _}} events only while the
caller has uncomsumed credit. When credit is exhausted, the worker
holds the next chunk and emits {nhttpc, Handle, {pause, BytesBuffered}};
after consume/2 drops the buffer below consume_low_water, a
{resume, BytesBuffered} event is emitted.
consume/2 is a no-op for non-streaming handles (send/2, ws_open/2),
for already-finished streams, and for streams started with
consume_initial => infinity.
-spec delete(binary()) -> {ok, nhttp_lib:response()} | {error, error()}.
DELETE request.
-spec delete(binary(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
DELETE request with options.
-spec flush(handle()) -> ok.
Drain any pending {nhttpc, Handle, _} messages from the caller's mailbox.
Idempotent and never blocks. Useful after a unified-protocol request
already completed to discard the lingering {done, _} event.
-spec get(binary()) -> {ok, nhttp_lib:response()} | {error, error()}.
Simple GET request.
-spec get(binary(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
GET request with options.
-spec head(binary()) -> {ok, nhttp_lib:response()} | {error, error()}.
HEAD request.
-spec head(binary(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
HEAD request with options.
-spec open(target(), opts()) -> {ok, connection()} | {error, error()}.
Open a standalone connection to Target, returning an opaque
connection() the caller passes via via => {conn, _} on subsequent
requests.
Useful primarily for HTTP/2 and HTTP/3 multiplexing where one
transport carries many concurrent requests. The handshake (TCP + TLS,
or QUIC) completes synchronously; a failure surfaces as
{error, {connect_error, _}}.
-spec options(binary()) -> {ok, nhttp_lib:response()} | {error, error()}.
OPTIONS request.
-spec options(binary(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
OPTIONS request with options.
Parse a URL into components.
Supports the http, https, ws, and wss schemes.
-spec patch(binary(), iodata()) -> {ok, nhttp_lib:response()} | {error, error()}.
PATCH request with body.
-spec patch(binary(), iodata(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
PATCH request with body and options.
-spec post(binary(), iodata()) -> {ok, nhttp_lib:response()} | {error, error()}.
POST request with body.
-spec post(binary(), iodata(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
POST request with body and options.
-spec put(binary(), iodata()) -> {ok, nhttp_lib:response()} | {error, error()}.
PUT request with body.
-spec put(binary(), iodata(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
PUT request with body and options.
-spec request(request()) -> {ok, nhttp_lib:response()} | {error, error()}.
Send a request and wait for the response.
-spec request(request(), opts()) -> {ok, nhttp_lib:response()} | {error, error()}.
Send a request with options.
-spec send(nhttp_lib:request()) -> {ok, handle()} | {error, error()}.
Send an async request returning a unified handle.
The handle delivers events as {nhttpc, Handle, Event} messages to the
caller. The terminal event is {done, normal} after {response, _} or
{error, _} on failure.
-spec send(nhttp_lib:request(), opts()) -> {ok, handle()} | {error, error()}.
Send an async request with options. Returns a unified handle.
-spec send_request(pid(), nhttp_lib:request()) -> {ok, reference()}.
Send request asynchronously on a specific connection.
This is for users who manage connections directly (e.g., via pools).
Returns immediately with a StreamRef. Response delivered via message:
{nhttpc_response, ConnPid, StreamRef, {ok, Response}}
{nhttpc_error, ConnPid, StreamRef, Reason}
Use await_response/3 for convenient selective receive.
{ok, ConnPid} = nhttpc_conn:start_link(Opts),
{ok, StreamRef} = nhttpc:send_request(ConnPid, Req),
{ok, Response} = nhttpc:await_response(ConnPid, StreamRef, 5000).
Start a client pool linked to the calling process.
Stop a client pool immediately.
Stop a client pool with options.
Mirrors nhttp:stop/2 (server side). Accepted options:
drain => false(default) - stop immediately, equivalent tostop/1.drain => true- stop accepting new checkouts, wait for in-flight requests to complete, then shut down. The waiter queue is released immediately with{error, pool_closed}.drain_timeout => timeout()- upper bound on drain (default 10000 ms). Only meaningful whendrain => true.force_kill_delay => non_neg_integer()- milliseconds to wait for the supervisor to terminate politely before sendingexit/2withkill.
-spec stream(nhttp_lib:request()) -> {ok, handle()} | {error, error()}.
Open a streaming request, returning a unified handle().
Per-chunk events are delivered as {nhttpc, Handle, Event} messages on
the caller's mailbox:
{nhttpc, Handle, {headers, Status, Headers}}
{nhttpc, Handle, {data, Chunk, more}}
{nhttpc, Handle, {trailers, Headers}} %% HTTP/2 + HTTP/3 only
{nhttpc, Handle, {done, normal}} %% terminal
{nhttpc, Handle, {error, Reason}} %% terminalStreaming is always one-shot for the duration of plan 07b — via other
than the implicit oneshot is rejected, and body_to is not accepted
because streaming downloads emit events instead of writing to a sink.
-spec stream(nhttp_lib:request(), opts()) -> {ok, handle()} | {error, error()}.
Open a streaming request with options. See stream/1.
-spec url(nhttp_lib:method(), binary()) -> {ok, nhttp_lib:request()} | {error, error()}.
Build a canonical nhttp_lib:request() from a method and URL.
Parses the URL and fills method, scheme, authority, path, and
an empty headers list on the result. Callers merge in their own
headers and body before dispatching:
{ok, Req0} = nhttpc:url(get, <<"https://api.example.com/users/42">>),
Req = Req0#{headers => [{<<"accept">>, <<"application/json">>}]},
nhttpc:request(Req).url/2 accepts http, https, ws, and wss URLs.
Open a WebSocket connection, returning a handle().
The owner receives events through the {nhttpc, Handle, _} envelope:
{nhttpc, Handle, {ws_ready, ConnInfo}} %% subprotocol negotiation result
{nhttpc, Handle, {ws, Frame}} %% per frame
{nhttpc, Handle, {done, normal}} %% terminal: clean close
{nhttpc, Handle, {done, {remote_closed, Code}}} %% terminal: server-initiated
{nhttpc, Handle, {error, Reason}} %% terminal: timeout / protocolWebSocket is always oneshot — the handshake consumes the transport so
neither {conn, _} nor {pool, _} dispatch is meaningful. Any
non-default via returns {error, {invalid_opts, _}}.
-spec ws_send(handle(), nhttp_lib:ws_frame()) -> ok | {error, error()}.
Send a WebSocket frame on a handle returned by ws_open/2.
Frame is an nhttp_lib:ws_frame() — {text, _}, {binary, _},
{ping, _}, or {pong, _}. Returns {error, {invalid_request, _}}
if the handle was not opened for WebSocket, and
{error, {ws_closed, noproc}} if the WS process has already exited.