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 NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
UNRELEASED
----------

### Breaking

- Removed the built-in metrics subsystem (`hackney_metrics`,
`hackney_metrics_backend`, `hackney_metrics_prometheus`,
`hackney_metrics_dummy`). Hackney no longer emits request or pool
metrics on its own and the `metrics_backend` app-env is no longer
read. In its place, `hackney:request/1..5` runs a chain of
user-supplied middleware (Go-style `RoundTripper`) configured via the
`{middleware, [Fun, ...]}` option or `application:set_env(hackney,
middleware, [...])`. See `guides/middleware.md` for the API, chain
semantics, and worked prometheus / telemetry recipes. Pool state is
still observable via `hackney_pool:get_stats/1`.

### Bug Fixes

- Fix HTTP/2 pooled connections wedging under sustained concurrent load
Expand Down
3 changes: 3 additions & 0 deletions guides/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
- **`with_body` option**: Deprecated and ignored
- **`hackney:body/1,2` and `hackney:stream_body/1`**: Deprecated - use async mode for streaming
- **Async mode**: Now works consistently across HTTP/1.1, HTTP/2, and HTTP/3
- **Metrics removed**: `hackney_metrics` and the prometheus/dummy
backends are gone. Metrics are now user-supplied middleware. See the
[Middleware Guide](middleware.md) for the prometheus migration recipe.

### Breaking Changes

Expand Down
189 changes: 189 additions & 0 deletions guides/middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Middleware Guide

Hackney supports a RoundTripper-style middleware layer around
`hackney:request/1..5`. A middleware can observe, rewrite, short-circuit
or wrap a request/response pair. The API is a plain fun — no behaviour,
no registry, no deps.

```erlang
-type request() :: #{method := atom() | binary(),
url := #hackney_url{},
headers := [{binary(), binary()}],
body := term(),
options := [term()]}.

-type response() :: {ok, integer(), list(), binary()}
| {ok, integer(), list()} %% HEAD
| {ok, reference()} %% async
| {ok, pid()} %% streaming upload
| {error, term()}.

-type next() :: fun((request()) -> response()).
-type middleware() :: fun((request(), next()) -> response()).
```

## Chain order

Outermost first. `[A, B, C]` means A wraps B wraps C: the request flows
`A → B → C → transport` and the response unwinds `transport → C → B → A`.
First in the list sees the request first and the response last — same
convention as Go's `http.RoundTripper`, Elixir Plug, Ruby Rack and
Tower-rs.

## Installing a chain

Per-request — overrides the global chain:

```erlang
hackney:request(get, URL, [], <<>>,
[{middleware, [Mw1, Mw2]}]).
```

Global fallback — applied to every request that doesn't set `middleware`:

```erlang
application:set_env(hackney, middleware, [Mw1, Mw2]).
```

Per-request **replaces** the global list. If you want to compose, merge
explicitly in your own code.

## Scope

Middleware runs around `hackney:request/1..5` only. The low-level
`hackney:connect/*` + `hackney:send_request/2` path bypasses middleware
— it's the raw transport, equivalent to Go's `http.Transport`.

Middleware sees whatever `Next` returns. For async and streaming bodies
that's a bare `{ok, Ref}` or `{ok, ConnPid}`; later message delivery is
the caller's problem. If you want to observe completion in async mode
you'll need to proxy `stream_to`.

If a middleware crashes, the exception propagates to the caller.
Hackney does not wrap user code in `try/catch`.

## Recipes

### Log every call

```erlang
Log = fun(Req, Next) ->
T0 = erlang:monotonic_time(millisecond),
Resp = Next(Req),
Status = case Resp of
{ok, S, _, _} -> S;
{ok, S, _} -> S;
_ -> error
end,
logger:info("~p ~s -> ~p (~pms)",
[maps:get(method, Req),
hackney_url:unparse_url(maps:get(url, Req)),
Status,
erlang:monotonic_time(millisecond) - T0]),
Resp
end,
hackney:get(URL, [], <<>>, [{middleware, [Log]}]).
```

### Add a header to every request

```erlang
AddHeader = fun(Req, Next) ->
H = maps:get(headers, Req),
Next(Req#{headers := [{<<"x-trace-id">>, trace_id()} | H]})
end.
```

### Retry on transient errors

```erlang
Retry = fun Self(Req, Next) ->
case Next(Req) of
{error, timeout} -> Self(Req, Next);
Other -> Other
end
end.
```

(For real retries add a counter and a backoff; kept minimal here.)

### Short-circuit / cache

A middleware that doesn't call `Next` returns its own response and the
request never leaves the process:

```erlang
Cache = fun(Req, Next) ->
Key = cache_key(Req),
case cache_get(Key) of
{ok, Resp} -> Resp;
miss -> cache_put(Key, Next(Req))
end
end.
```

## Migrating from `hackney_metrics`

The `hackney_metrics` module and its prometheus/dummy backends have been
removed. Hackney no longer emits any metrics itself. Port your metrics
into a middleware:

### Prometheus

```erlang
PromMetrics = fun(Req, Next) ->
T0 = erlang:monotonic_time(millisecond),
#hackney_url{host = Host} = maps:get(url, Req),
HostBin = iolist_to_binary(Host),
prometheus_counter:inc(hackney_requests_total, [HostBin]),
prometheus_gauge:inc(hackney_requests_active, [HostBin]),
Resp = Next(Req),
prometheus_gauge:dec(hackney_requests_active, [HostBin]),
prometheus_counter:inc(hackney_requests_finished_total, [HostBin]),
Dt = (erlang:monotonic_time(millisecond) - T0) / 1000,
prometheus_histogram:observe(hackney_request_duration_seconds,
[HostBin], Dt),
Resp
end,
application:set_env(hackney, middleware, [PromMetrics]).
```

Declare the same counter/gauge/histogram at startup as before — the
middleware just emits the numbers, it does not own the registry.

### Telemetry

```erlang
Telemetry = fun(Req, Next) ->
T0 = erlang:monotonic_time(),
telemetry:execute([hackney, request, start],
#{system_time => erlang:system_time()},
#{method => maps:get(method, Req)}),
try
Resp = Next(Req),
telemetry:execute([hackney, request, stop],
#{duration => erlang:monotonic_time() - T0},
#{result => Resp}),
Resp
catch Class:Reason:Stack ->
telemetry:execute([hackney, request, exception],
#{duration => erlang:monotonic_time() - T0},
#{kind => Class, reason => Reason,
stacktrace => Stack}),
erlang:raise(Class, Reason, Stack)
end
end.
```

### Pool observability

`hackney_pool_free_count`, `hackney_pool_in_use_count` and
`hackney_pool_checkouts_total` are gone with the metrics module — they
reflect pool state, not per-request events, and can't be expressed as
middleware. Use `hackney_pool:get_stats/1` from your own metrics
collector to sample pool state at whatever cadence you want:

```erlang
Stats = hackney_pool:get_stats(default),
%% #{name, max, in_use_count, free_count, queue_count}
```
15 changes: 2 additions & 13 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,8 @@
]}.

{xref_checks, [undefined_function_calls]}.
%% Ignore xref warnings for optional dependencies (prometheus, quic)
%% Ignore xref warnings for optional dependencies (quic)
{xref_ignores, [
%% prometheus (optional metrics backend)
{prometheus_counter, inc, 3},
{prometheus_counter, declare, 1},
{prometheus_gauge, set, 3},
{prometheus_gauge, inc, 2},
{prometheus_gauge, dec, 2},
{prometheus_gauge, declare, 1},
{prometheus_histogram, observe, 3},
{prometheus_histogram, declare, 1},
%% quic / quic_h3 (HTTP/3 support)
{quic_h3, connect, 3},
{quic_h3, request, 2},
Expand Down Expand Up @@ -89,6 +80,7 @@
{"guides/http2_guide.md", #{title => "HTTP/2 Guide"}},
{"guides/http3_guide.md", #{title => "HTTP/3 Guide"}},
{"guides/websocket_guide.md", #{title => "WebSocket Guide"}},
{"guides/middleware.md", #{title => "Middleware Guide"}},
{"guides/design.md", #{title => "Design Guide"}},
{"guides/MIGRATION.md", #{title => "Migration Guide"}},
{"NEWS.md", #{title => "Changelog"}},
Expand All @@ -112,9 +104,6 @@
error_handling%,
%unknown
]},
%% Exclude modules with optional dependencies
%% - hackney_metrics_prometheus: prometheus is optional
{exclude_mods, [hackney_metrics_prometheus]},
{base_plt_apps, [erts, stdlib, kernel, crypto, runtime_tools]},
{plt_apps, top_level_deps},
{plt_extra_apps, [quic, h2]},
Expand Down
40 changes: 15 additions & 25 deletions src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ connect_direct(Transport, Host, Port, Options) ->
{ok, ConnPid} ->
case hackney_conn:connect(ConnPid) of
ok ->
hackney_manager:start_request(Host),
{ok, ConnPid};
{error, Reason} ->
catch hackney_conn:stop(ConnPid),
Expand Down Expand Up @@ -152,7 +151,6 @@ connect_pool(Transport, Host, Port, Options) ->
%% Check HTTP/3 first if allowed
case H3Allowed andalso try_h3_connection(Host, Port, Transport, Options, PoolHandler) of
{ok, H3Pid} ->
hackney_manager:start_request(Host),
{ok, H3Pid};
_ when H2Allowed ->
%% Try HTTP/2 multiplexing
Expand All @@ -162,8 +160,7 @@ connect_pool(Transport, Host, Port, Options) ->
%% (OTP 28 on FreeBSD may have timing issues with SSL connections)
case hackney_conn:get_state(H2Pid) of
{ok, connected} ->
hackney_manager:start_request(Host),
{ok, H2Pid};
{ok, H2Pid};
_ ->
%% Connection not ready, unregister and create new
PoolHandler:unregister_h2(H2Pid, Options),
Expand Down Expand Up @@ -275,8 +272,7 @@ connect_pool_new(Transport, Host, Port, Options, PoolHandler) ->
ok ->
%% Check if HTTP/2 was negotiated, register for multiplexing
maybe_register_h2(ConnPid, Host, Port, Transport, Options, PoolHandler),
hackney_manager:start_request(Host),
{ok, ConnPid};
{ok, ConnPid};
{error, Reason} ->
%% Upgrade failed - release slot and close connection
hackney_load_regulation:release(Host, Port),
Expand Down Expand Up @@ -374,7 +370,6 @@ start_conn_with_socket_internal(Host, Port, Transport, Socket, Options) ->
},
case hackney_conn_sup:start_conn(ConnOpts) of
{ok, ConnPid} ->
hackney_manager:start_request(Host),
{ok, ConnPid};
{error, Reason} ->
{error, Reason}
Expand Down Expand Up @@ -473,6 +468,17 @@ request(Method, #hackney_url{}=URL0, Headers0, Body, Options0) ->
{body, Body},
{options, Options0}]),

Req = #{method => Method,
url => URL,
headers => Headers0,
body => Body,
options => Options0},
Chain = hackney_middleware:resolve_chain(Options0),
hackney_middleware:apply_chain(Chain, Req, fun do_dispatch/1).

%% @private Terminal of the middleware chain: the actual request dispatch.
do_dispatch(#{method := Method, url := URL,
headers := Headers0, body := Body, options := Options0}) ->
#hackney_url{transport=Transport,
scheme = Scheme,
host = Host,
Expand Down Expand Up @@ -775,7 +781,7 @@ location(ConnPid) when is_pid(ConnPid) ->
%% Internal functions
%%====================================================================

do_request(ConnPid, Method, Path, Headers0, Body, Options, URL, Host) ->
do_request(ConnPid, Method, Path, Headers0, Body, Options, URL, _Host) ->
%% Build headers
Headers1 = hackney_headers:new(Headers0),
Headers2 = add_host_header(URL, Headers1),
Expand All @@ -798,30 +804,14 @@ do_request(ConnPid, Method, Path, Headers0, Body, Options, URL, Host) ->
%% Convert method to binary
MethodBin = hackney_bstr:to_upper(hackney_bstr:to_binary(Method)),

StartTime = os:timestamp(),

Result = case Async of
case Async of
false ->
%% Sync request with redirect handling
sync_request_with_redirect(ConnPid, MethodBin, Path, Headers3, Body, WithBody,
Options, URL, FollowRedirect, MaxRedirect, RedirectCount);
_ ->
%% Async request with optional redirect handling
async_request(ConnPid, MethodBin, Path, Headers3, Body, Async, StreamTo, FollowRedirect, Options)
end,

case Result of
{ok, _, _, _} ->
hackney_manager:finish_request(Host, StartTime),
Result;
{ok, _, _} ->
hackney_manager:finish_request(Host, StartTime),
Result;
{ok, _} ->
Result; % Async - don't finish yet
{error, _} ->
hackney_manager:finish_request(Host, StartTime),
Result
end.

sync_request_with_redirect(ConnPid, Method, Path, Headers, Body, WithBody, Options, URL,
Expand Down
Loading
Loading