Middleware Guide

View Source

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.

-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}