Middleware Guide
View SourceHackney 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.
-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:
hackney:request(get, URL, [], <<>>,
[{middleware, [Mw1, Mw2]}]).Global fallback — applied to every request that doesn't set middleware:
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
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
AddHeader = fun(Req, Next) ->
H = maps:get(headers, Req),
Next(Req#{headers := [{<<"x-trace-id">>, trace_id()} | H]})
end.Retry on transient errors
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:
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
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
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:
Stats = hackney_pool:get_stats(default),
%% #{name, max, in_use_count, free_count, queue_count}