From 3cda81b170fb4f471fb07a9162520690721f9942 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 16 Apr 2026 02:54:14 +0200 Subject: [PATCH 1/2] feat(middleware): RoundTripper-style chain; drop hackney_metrics Fun-based middleware around hackney:request/1..5, outermost-first, per-request or app-env. send_request/2 bypasses. Metrics subsystem removed; guides/middleware.md shows prometheus/telemetry recipes. --- NEWS.md | 13 ++ guides/MIGRATION.md | 3 + guides/middleware.md | 189 ++++++++++++++++++ rebar.config | 15 +- src/hackney.erl | 40 ++-- src/hackney_manager.erl | 49 +---- src/hackney_metrics.erl | 133 ------------ src/hackney_metrics_backend.erl | 33 --- src/hackney_metrics_dummy.erl | 44 ---- src/hackney_metrics_prometheus.erl | 115 ----------- src/hackney_middleware.erl | 95 +++++++++ src/hackney_pool.erl | 35 +--- src/hackney_sup.erl | 3 - src/hackney_util.erl | 9 - test/hackney_metrics_tests.erl | 71 ------- test/hackney_middleware_integration_tests.erl | 95 +++++++++ test/hackney_middleware_tests.erl | 124 ++++++++++++ test/hackney_util_tests.erl | 14 -- 18 files changed, 545 insertions(+), 535 deletions(-) create mode 100644 guides/middleware.md delete mode 100644 src/hackney_metrics.erl delete mode 100644 src/hackney_metrics_backend.erl delete mode 100644 src/hackney_metrics_dummy.erl delete mode 100644 src/hackney_metrics_prometheus.erl create mode 100644 src/hackney_middleware.erl delete mode 100644 test/hackney_metrics_tests.erl create mode 100644 test/hackney_middleware_integration_tests.erl create mode 100644 test/hackney_middleware_tests.erl diff --git a/NEWS.md b/NEWS.md index 4e834108..6845e6a8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 diff --git a/guides/MIGRATION.md b/guides/MIGRATION.md index 3a09cfd7..0cb91610 100644 --- a/guides/MIGRATION.md +++ b/guides/MIGRATION.md @@ -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 diff --git a/guides/middleware.md b/guides/middleware.md new file mode 100644 index 00000000..e2f68de9 --- /dev/null +++ b/guides/middleware.md @@ -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: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} +``` diff --git a/rebar.config b/rebar.config index 6f76a53d..9fece7b2 100644 --- a/rebar.config +++ b/rebar.config @@ -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}, @@ -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"}}, @@ -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]}, diff --git a/src/hackney.erl b/src/hackney.erl index d4b0b3cb..0b988e89 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -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), @@ -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 @@ -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), @@ -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), @@ -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} @@ -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, @@ -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), @@ -798,9 +804,7 @@ 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, @@ -808,20 +812,6 @@ do_request(ConnPid, Method, Path, Headers0, Body, Options, URL, Host) -> _ -> %% 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, diff --git a/src/hackney_manager.erl b/src/hackney_manager.erl index 1f6ca151..b2b93946 100644 --- a/src/hackney_manager.erl +++ b/src/hackney_manager.erl @@ -3,17 +3,16 @@ %%% This file is part of hackney released under the Apache 2 license. %%% See the NOTICE for more information. %%% -%%% Simplified manager that only handles metrics. -%%% Request tracking is no longer needed - hackney_conn processes -%%% manage their own lifecycle and monitor their owners. +%%% The manager used to drive request counters and a duration histogram +%%% via `hackney_metrics'. Those hooks are gone — metrics are now +%%% emitted by user middleware (see `hackney_middleware' and +%%% `guides/middleware.md'). What remains is a tiny gen_server kept for +%%% the backward-compatible `get_state/1' and `async_response_pid/1' +%%% accessors that older callers still rely on. -module(hackney_manager). -behaviour(gen_server). -%% Metrics API --export([start_request/1, - finish_request/2]). - %% Backward compatibility API -export([get_state/1, async_response_pid/1]). @@ -28,16 +27,6 @@ %% API %%==================================================================== -%% @doc Called when a new request starts. Updates request counters. --spec start_request(Host :: string() | binary()) -> ok. -start_request(Host) -> - gen_server:cast(?MODULE, {start_request, Host}). - -%% @doc Called when a request finishes. Updates metrics. --spec finish_request(Host :: string() | binary(), StartTime :: erlang:timestamp()) -> ok. -finish_request(Host, StartTime) -> - gen_server:cast(?MODULE, {finish_request, Host, StartTime}). - %% @doc Check the state of a connection (backward compatibility). %% In the old architecture, this tracked request state. %% In the new architecture, we simply check if the connection process is alive. @@ -81,24 +70,6 @@ init([]) -> handle_call(_Request, _From, State) -> {reply, ok, State}. -handle_cast({start_request, Host}, State) -> - HostBin = to_binary(Host), - Labels = #{host => HostBin}, - _ = hackney_metrics:counter_inc(hackney_requests_total, Labels), - _ = hackney_metrics:gauge_inc(hackney_requests_active, Labels), - {noreply, State}; - -handle_cast({finish_request, Host, StartTime}, State) -> - HostBin = to_binary(Host), - Labels = #{host => HostBin}, - %% Calculate duration in seconds (Prometheus convention) - DurationMicros = timer:now_diff(os:timestamp(), StartTime), - DurationSeconds = DurationMicros / 1000000, - _ = hackney_metrics:histogram_observe(hackney_request_duration_seconds, Labels, DurationSeconds), - _ = hackney_metrics:gauge_dec(hackney_requests_active, Labels), - _ = hackney_metrics:counter_inc(hackney_requests_finished_total, Labels), - {noreply, State}; - handle_cast(_Msg, State) -> {noreply, State}. @@ -110,11 +81,3 @@ terminate(_Reason, _State) -> code_change(_OldVsn, State, _Extra) -> {ok, State}. - -%%==================================================================== -%% Internal functions -%%==================================================================== - -to_binary(Host) when is_binary(Host) -> Host; -to_binary(Host) when is_list(Host) -> list_to_binary(Host); -to_binary(Host) when is_atom(Host) -> atom_to_binary(Host, utf8). diff --git a/src/hackney_metrics.erl b/src/hackney_metrics.erl deleted file mode 100644 index f6f46527..00000000 --- a/src/hackney_metrics.erl +++ /dev/null @@ -1,133 +0,0 @@ -%%% -*- erlang -*- -%%% -%%% This file is part of hackney released under the Apache 2 license. -%%% See the NOTICE for more information. -%%% -%%% Copyright (c) 2012-2026 Benoît Chesneau -%%% - --module(hackney_metrics). --author("benoitc"). - -%% API --export([ - init/0, - get_backend/0 -]). - -%% Counter operations --export([ - counter_inc/2, - counter_inc/3 -]). - -%% Gauge operations --export([ - gauge_set/3, - gauge_inc/2, - gauge_dec/2 -]). - -%% Histogram operations --export([ - histogram_observe/3 -]). - -%% Metric declarations --export([ - declare_counter/3, - declare_gauge/3, - declare_histogram/3, - declare_histogram/4, - declare_pool_metrics/1 -]). - --include("hackney.hrl"). - -%% Default duration histogram buckets (in seconds) --define(DEFAULT_DURATION_BUCKETS, [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]). - -%% @doc Initialize the metrics system. -%% Determines the backend to use and declares all hackney metrics. -init() -> - Backend = hackney_util:mod_metrics(), - ets:insert(?CONFIG, {metrics_backend, Backend}), - declare_metrics(Backend). - -%% @doc Get the current metrics backend module. -get_backend() -> - try - ets:lookup_element(?CONFIG, metrics_backend, 2) - catch - error:badarg -> - %% ETS table not ready yet, return dummy backend - hackney_metrics_dummy - end. - -%% @doc Increment a counter by 1. -counter_inc(Name, Labels) -> - (get_backend()):counter_inc(Name, Labels). - -%% @doc Increment a counter by a value. -counter_inc(Name, Labels, Value) -> - (get_backend()):counter_inc(Name, Labels, Value). - -%% @doc Set a gauge to a value. -gauge_set(Name, Labels, Value) -> - (get_backend()):gauge_set(Name, Labels, Value). - -%% @doc Increment a gauge by 1. -gauge_inc(Name, Labels) -> - (get_backend()):gauge_inc(Name, Labels). - -%% @doc Decrement a gauge by 1. -gauge_dec(Name, Labels) -> - (get_backend()):gauge_dec(Name, Labels). - -%% @doc Observe a value for a histogram. -histogram_observe(Name, Labels, Value) -> - (get_backend()):histogram_observe(Name, Labels, Value). - -%% @doc Declare a counter metric. -declare_counter(Name, Help, LabelKeys) -> - (get_backend()):declare_counter(Name, Help, LabelKeys). - -%% @doc Declare a gauge metric. -declare_gauge(Name, Help, LabelKeys) -> - (get_backend()):declare_gauge(Name, Help, LabelKeys). - -%% @doc Declare a histogram metric with default buckets. -declare_histogram(Name, Help, LabelKeys) -> - (get_backend()):declare_histogram(Name, Help, LabelKeys). - -%% @doc Declare a histogram metric with custom buckets. -declare_histogram(Name, Help, LabelKeys, Buckets) -> - (get_backend()):declare_histogram(Name, Help, LabelKeys, Buckets). - -%% @doc Declare pool-specific metrics. -%% Called when a new pool is created. -declare_pool_metrics(_PoolName) -> - Backend = get_backend(), - %% Only declare once (idempotent for prometheus) - Backend:declare_gauge(hackney_pool_free_count, - <<"Number of free/available connections in the pool">>, [pool]), - Backend:declare_gauge(hackney_pool_in_use_count, - <<"Number of connections currently in use">>, [pool]), - Backend:declare_counter(hackney_pool_checkouts_total, - <<"Total number of connection checkouts">>, [pool]), - ok. - -%% @private -%% Declare all hackney metrics at startup. -declare_metrics(Backend) -> - %% Request metrics - Backend:declare_counter(hackney_requests_total, - <<"Total number of HTTP requests started">>, [host]), - Backend:declare_gauge(hackney_requests_active, - <<"Number of currently active HTTP requests">>, [host]), - Backend:declare_counter(hackney_requests_finished_total, - <<"Total number of HTTP requests finished">>, [host]), - Backend:declare_histogram(hackney_request_duration_seconds, - <<"HTTP request duration in seconds">>, [host], ?DEFAULT_DURATION_BUCKETS), - %% Pool metrics are declared when pools are created - ok. diff --git a/src/hackney_metrics_backend.erl b/src/hackney_metrics_backend.erl deleted file mode 100644 index 963bb543..00000000 --- a/src/hackney_metrics_backend.erl +++ /dev/null @@ -1,33 +0,0 @@ -%%% -*- erlang -*- -%%% -%%% This file is part of hackney released under the Apache 2 license. -%%% See the NOTICE for more information. -%%% -%%% Copyright (c) 2012-2026 Benoît Chesneau -%%% - --module(hackney_metrics_backend). --author("benoitc"). - -%% Behaviour callbacks for hackney metrics backends -%% -%% Implementations must export all callback functions. -%% See hackney_metrics_dummy for a reference implementation. - -%% Counter operations (monotonically increasing) --callback counter_inc(Name :: atom(), Labels :: map()) -> ok. --callback counter_inc(Name :: atom(), Labels :: map(), Value :: number()) -> ok. - -%% Gauge operations (can go up or down) --callback gauge_set(Name :: atom(), Labels :: map(), Value :: number()) -> ok. --callback gauge_inc(Name :: atom(), Labels :: map()) -> ok. --callback gauge_dec(Name :: atom(), Labels :: map()) -> ok. - -%% Histogram operations (for timing/distribution measurements) --callback histogram_observe(Name :: atom(), Labels :: map(), Value :: number()) -> ok. - -%% Metric lifecycle --callback declare_counter(Name :: atom(), Help :: binary(), LabelKeys :: [atom()]) -> ok. --callback declare_gauge(Name :: atom(), Help :: binary(), LabelKeys :: [atom()]) -> ok. --callback declare_histogram(Name :: atom(), Help :: binary(), LabelKeys :: [atom()]) -> ok. --callback declare_histogram(Name :: atom(), Help :: binary(), LabelKeys :: [atom()], Buckets :: [number()]) -> ok. diff --git a/src/hackney_metrics_dummy.erl b/src/hackney_metrics_dummy.erl deleted file mode 100644 index da824df1..00000000 --- a/src/hackney_metrics_dummy.erl +++ /dev/null @@ -1,44 +0,0 @@ -%%% -*- erlang -*- -%%% -%%% This file is part of hackney released under the Apache 2 license. -%%% See the NOTICE for more information. -%%% -%%% Copyright (c) 2012-2026 Benoît Chesneau -%%% - --module(hackney_metrics_dummy). --author("benoitc"). - --behaviour(hackney_metrics_backend). - -%% hackney_metrics_backend callbacks --export([ - counter_inc/2, - counter_inc/3, - gauge_set/3, - gauge_inc/2, - gauge_dec/2, - histogram_observe/3, - declare_counter/3, - declare_gauge/3, - declare_histogram/3, - declare_histogram/4 -]). - -%% Counter operations - no-op -counter_inc(_Name, _Labels) -> ok. -counter_inc(_Name, _Labels, _Value) -> ok. - -%% Gauge operations - no-op -gauge_set(_Name, _Labels, _Value) -> ok. -gauge_inc(_Name, _Labels) -> ok. -gauge_dec(_Name, _Labels) -> ok. - -%% Histogram operations - no-op -histogram_observe(_Name, _Labels, _Value) -> ok. - -%% Metric lifecycle - no-op -declare_counter(_Name, _Help, _LabelKeys) -> ok. -declare_gauge(_Name, _Help, _LabelKeys) -> ok. -declare_histogram(_Name, _Help, _LabelKeys) -> ok. -declare_histogram(_Name, _Help, _LabelKeys, _Buckets) -> ok. diff --git a/src/hackney_metrics_prometheus.erl b/src/hackney_metrics_prometheus.erl deleted file mode 100644 index 965b78e9..00000000 --- a/src/hackney_metrics_prometheus.erl +++ /dev/null @@ -1,115 +0,0 @@ -%%% -*- erlang -*- -%%% -%%% This file is part of hackney released under the Apache 2 license. -%%% See the NOTICE for more information. -%%% -%%% Copyright (c) 2012-2026 Benoît Chesneau -%%% - --module(hackney_metrics_prometheus). --author("benoitc"). - --behaviour(hackney_metrics_backend). - -%% hackney_metrics_backend callbacks --export([ - counter_inc/2, - counter_inc/3, - gauge_set/3, - gauge_inc/2, - gauge_dec/2, - histogram_observe/3, - declare_counter/3, - declare_gauge/3, - declare_histogram/3, - declare_histogram/4 -]). - -%% Default histogram buckets for duration metrics (in seconds) --define(DEFAULT_BUCKETS, [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]). - -%% Counter operations -counter_inc(Name, Labels) -> - counter_inc(Name, Labels, 1). - -counter_inc(Name, Labels, Value) -> - try - prometheus_counter:inc(Name, labels_to_list(Labels), Value) - catch - _:_ -> ok - end. - -%% Gauge operations -gauge_set(Name, Labels, Value) -> - try - prometheus_gauge:set(Name, labels_to_list(Labels), Value) - catch - _:_ -> ok - end. - -gauge_inc(Name, Labels) -> - try - prometheus_gauge:inc(Name, labels_to_list(Labels)) - catch - _:_ -> ok - end. - -gauge_dec(Name, Labels) -> - try - prometheus_gauge:dec(Name, labels_to_list(Labels)) - catch - _:_ -> ok - end. - -%% Histogram operations -histogram_observe(Name, Labels, Value) -> - try - prometheus_histogram:observe(Name, labels_to_list(Labels), Value) - catch - _:_ -> ok - end. - -%% Metric lifecycle - declarations are idempotent in prometheus -declare_counter(Name, Help, LabelKeys) -> - try - prometheus_counter:declare([ - {name, Name}, - {help, Help}, - {labels, LabelKeys} - ]) - catch - _:_ -> ok - end. - -declare_gauge(Name, Help, LabelKeys) -> - try - prometheus_gauge:declare([ - {name, Name}, - {help, Help}, - {labels, LabelKeys} - ]) - catch - _:_ -> ok - end. - -declare_histogram(Name, Help, LabelKeys) -> - declare_histogram(Name, Help, LabelKeys, ?DEFAULT_BUCKETS). - -declare_histogram(Name, Help, LabelKeys, Buckets) -> - try - prometheus_histogram:declare([ - {name, Name}, - {help, Help}, - {labels, LabelKeys}, - {buckets, Buckets} - ]) - catch - _:_ -> ok - end. - -%% Internal helpers - -%% Convert a map of labels to a list of values in the order expected by prometheus -%% The order is determined by the keys when sorted alphabetically -labels_to_list(Labels) when is_map(Labels) -> - lists:map(fun({_K, V}) -> V end, lists:keysort(1, maps:to_list(Labels))). diff --git a/src/hackney_middleware.erl b/src/hackney_middleware.erl new file mode 100644 index 00000000..3c3f7034 --- /dev/null +++ b/src/hackney_middleware.erl @@ -0,0 +1,95 @@ +%%% -*- erlang -*- +%%% +%%% This file is part of hackney released under the Apache 2 license. +%%% See the NOTICE for more information. +%%% +%%% @doc RoundTripper-style middleware for hackney:request/1..5. +%%% +%%% A middleware is a fun `fun((Request, Next) -> Response)'. `Next' is +%%% itself a fun that takes a (possibly rewritten) request and returns +%%% the response. Middleware can observe, rewrite, short-circuit, or +%%% wrap the downstream call. +%%% +%%% Chains are outermost-first: `[A, B, C]' means A wraps B wraps C, so +%%% the request flows `A -> B -> C -> transport' and the response +%%% unwinds the same way. This matches Go's `http.RoundTripper', Plug, +%%% Rack and Tower-rs. +%%% +%%% Middleware wraps the user-facing `hackney:request/1..5' only. The +%%% low-level `hackney:connect/*' + `hackney:send_request/2' path is +%%% intentionally unchanged. +%%% +%%% Example — a minimal duration logger: +%%% ``` +%%% Log = fun(Req, Next) -> +%%% T0 = erlang:monotonic_time(millisecond), +%%% Resp = Next(Req), +%%% logger:info("~p ~s -> ~pms", +%%% [maps:get(method, Req), +%%% hackney_url:unparse_url(maps:get(url, Req)), +%%% erlang:monotonic_time(millisecond) - T0]), +%%% Resp +%%% end, +%%% hackney:get(URL, [], <<>>, [{middleware, [Log]}]). +%%% ''' +-module(hackney_middleware). + +-export([apply_chain/3, + resolve_chain/1]). + +-export_type([request/0, response/0, next/0, middleware/0]). + +-type request() :: #{method := atom() | binary(), + url := hackney_url:hackney_url(), + headers := [{binary(), binary()}], + body := term(), + options := [term()]}. + +-type response() :: {ok, integer(), list(), binary()} + | {ok, integer(), list()} + | {ok, reference()} + | {ok, pid()} + | {error, term()}. + +-type next() :: fun((request()) -> response()). +-type middleware() :: fun((request(), next()) -> response()). + +%% @doc Apply `Chain' (outermost-first) around `Terminal'. +%% +%% The returned response is whatever the outermost middleware produces; +%% crashes propagate to the caller. +-spec apply_chain([middleware()], request(), next()) -> response(). +apply_chain([], Req, Terminal) -> + Terminal(Req); +apply_chain(Chain, Req, Terminal) when is_list(Chain) -> + Next = build(Chain, Terminal), + Next(Req). + +%% @doc Pick the middleware chain for a request. +%% +%% Per-request `{middleware, List}' in `Options' replaces the global +%% `application:get_env(hackney, middleware)' value. No implicit merge. +-spec resolve_chain([term()]) -> [middleware()]. +resolve_chain(Options) -> + case lists:keyfind(middleware, 1, Options) of + {middleware, List} when is_list(List) -> + List; + false -> + case application:get_env(hackney, middleware) of + {ok, List} when is_list(List) -> List; + _ -> [] + end + end. + +%% ============================================================================ +%% Internal +%% ============================================================================ + +%% Fold from the right so the head of Chain becomes the outermost call. +build(Chain, Terminal) -> + lists:foldr( + fun(M, Next) when is_function(M, 2) -> + fun(R) -> M(R, Next) end + end, + Terminal, + Chain). diff --git a/src/hackney_pool.erl b/src/hackney_pool.erl index 3b210f80..d554a873 100644 --- a/src/hackney_pool.erl +++ b/src/hackney_pool.erl @@ -398,9 +398,6 @@ init([Name, Options]) -> %% register the module ets:insert(?MODULE, {Name, self()}), - %% initialize pool metrics - hackney_metrics:declare_pool_metrics(Name), - {ok, #state{name=Name, max_connections=MaxConn, keepalive_timeout=KeepaliveTimeout, prewarm_count=PrewarmCount}}. @@ -451,10 +448,6 @@ handle_call({checkout, Key, Requester, Opts}, _From, State) -> %% Found an available connection - update owner to new requester ?report_debug("pool: reusing connection", [{pool, PoolName}, {pid, Pid}]), ok = hackney_conn:set_owner(Pid, Requester), - Labels = #{pool => PoolName}, - _ = hackney_metrics:counter_inc(hackney_pool_checkouts_total, Labels), - _ = hackney_metrics:gauge_inc(hackney_pool_in_use_count, Labels), - _ = hackney_metrics:gauge_dec(hackney_pool_free_count, Labels), InUse2 = maps:put(Pid, Key, InUse), {reply, {ok, Pid}, State#state{available=Available2, in_use=InUse2}}; none when TotalInUse >= MaxConn -> @@ -467,9 +460,6 @@ handle_call({checkout, Key, Requester, Opts}, _From, State) -> ?report_trace("pool: starting new connection", [{pool, PoolName}]), case start_connection(Key, Requester, Opts, State) of {ok, Pid, State2} -> - Labels = #{pool => PoolName}, - _ = hackney_metrics:counter_inc(hackney_pool_checkouts_total, Labels), - _ = hackney_metrics:gauge_inc(hackney_pool_in_use_count, Labels), InUse2 = maps:put(Pid, Key, State2#state.in_use), {reply, {ok, Pid}, State2#state{in_use=InUse2}}; {error, Reason} -> @@ -666,7 +656,7 @@ handle_info(_Info, State) -> code_change(_OldVsn, State, _Extra) -> {ok, State}. -terminate(_Reason, #state{name=PoolName, available=Available, +terminate(_Reason, #state{available=Available, h2_connections=H2Conns, h3_connections=H3Conns, pid_monitors=PidMonitors}) -> %% Stop all available connections @@ -697,11 +687,6 @@ terminate(_Reason, #state{name=PoolName, available=Available, %% Demonitor all maps:foreach(fun(_Pid, MonRef) -> erlang:demonitor(MonRef, [flush]) end, PidMonitors), - - %% Reset pool metrics to zero - Labels = #{pool => PoolName}, - _ = hackney_metrics:gauge_set(hackney_pool_in_use_count, Labels, 0), - _ = hackney_metrics:gauge_set(hackney_pool_free_count, Labels, 0), ok. %%==================================================================== @@ -784,7 +769,7 @@ start_connection(Key, Owner, Opts, State) -> %% Only TCP connections are stored. SSL upgraded connections are closed. %% Always releases the load_regulation slot since connection is no longer in use. do_checkin(Pid, State) -> - #state{name=PoolName, in_use=InUse, available=Available, pid_monitors=PidMonitors} = State, + #state{in_use=InUse, available=Available, pid_monitors=PidMonitors} = State, %% Get the key from in_use and remove case maps:take(Pid, InUse) of @@ -793,10 +778,6 @@ do_checkin(Pid, State) -> {Host, Port, _Transport} = Key, hackney_load_regulation:release(Host, Port), - %% Update metrics - connection no longer in use - Labels = #{pool => PoolName}, - _ = hackney_metrics:gauge_dec(hackney_pool_in_use_count, Labels), - %% Check if connection is still alive case is_process_alive(Pid) of true -> @@ -827,9 +808,6 @@ do_checkin(Pid, State) -> hackney_conn:set_owner_async(Pid, self()), Available2 = maps:update_with(Key, fun(Pids) -> [Pid | Pids] end, [Pid], Available), - %% Update metrics - connection now free - _ = hackney_metrics:gauge_inc(hackney_pool_free_count, Labels), - %% Ensure we're monitoring this pid PidMonitors2 = case maps:is_key(Pid, PidMonitors) of true -> PidMonitors; @@ -853,7 +831,7 @@ do_checkin(Pid, State) -> %% Used for sync checkin to prevent deadlock when connection calls pool. %% ShouldClose is true if connection was SSL upgraded or is a proxy tunnel (no_reuse). do_checkin_with_close_flag(Pid, ShouldClose, State) -> - #state{name=PoolName, in_use=InUse, available=Available, pid_monitors=PidMonitors} = State, + #state{in_use=InUse, available=Available, pid_monitors=PidMonitors} = State, %% Get the key from in_use and remove case maps:take(Pid, InUse) of @@ -862,10 +840,6 @@ do_checkin_with_close_flag(Pid, ShouldClose, State) -> {Host, Port, _Transport} = Key, hackney_load_regulation:release(Host, Port), - %% Update metrics - connection no longer in use - Labels = #{pool => PoolName}, - _ = hackney_metrics:gauge_dec(hackney_pool_in_use_count, Labels), - %% Check if connection is still alive case is_process_alive(Pid) of true -> @@ -892,9 +866,6 @@ do_checkin_with_close_flag(Pid, ShouldClose, State) -> hackney_conn:set_owner_async(Pid, self()), Available2 = maps:update_with(Key, fun(Pids) -> [Pid | Pids] end, [Pid], Available), - %% Update metrics - connection now free - _ = hackney_metrics:gauge_inc(hackney_pool_free_count, Labels), - %% Ensure we're monitoring this pid PidMonitors2 = case maps:is_key(Pid, PidMonitors) of true -> PidMonitors; diff --git a/src/hackney_sup.erl b/src/hackney_sup.erl index 1b759827..c05c3233 100644 --- a/src/hackney_sup.erl +++ b/src/hackney_sup.erl @@ -39,9 +39,6 @@ init([]) -> %% initialize the config table _ = ets:new(?CONFIG, [set, named_table, public]), - %% initialize the metric engine - hackney_metrics:init(), - %% initialize per-host load regulation hackney_load_regulation:init(), diff --git a/src/hackney_util.erl b/src/hackney_util.erl index f44bcfd5..ce7d6490 100644 --- a/src/hackney_util.erl +++ b/src/hackney_util.erl @@ -11,7 +11,6 @@ -export([maybe_apply_defaults/2]). -export([is_ipv6/1]). -export([privdir/0]). --export([mod_metrics/0]). -export([default_protocols/0]). -export([to_atom/1]). @@ -102,14 +101,6 @@ privdir() -> Dir -> Dir end. -mod_metrics() -> - case application:get_env(hackney, metrics_backend) of - {ok, prometheus} -> hackney_metrics_prometheus; - {ok, dummy} -> hackney_metrics_dummy; - {ok, Mod} -> Mod; - undefined -> hackney_metrics_dummy - end. - %% @doc Get the default protocols for HTTP connections. %% Returns the value of the `default_protocols` application env, %% or `[http2, http1]' if not set. diff --git a/test/hackney_metrics_tests.erl b/test/hackney_metrics_tests.erl deleted file mode 100644 index e1df93e0..00000000 --- a/test/hackney_metrics_tests.erl +++ /dev/null @@ -1,71 +0,0 @@ -%%% -*- erlang -*- -%%% -%%% This file is part of hackney released under the Apache 2 license. -%%% See the NOTICE for more information. -%%% --module(hackney_metrics_tests). --include_lib("eunit/include/eunit.hrl"). - --define(CONFIG, hackney_config). - -%% hackney_metrics requires the hackney_config ETS table to exist -%% These tests set up a minimal environment - -metrics_test_() -> - {setup, - fun setup/0, - fun teardown/1, - [ - {"init inserts metrics_backend into ETS", - fun() -> - hackney_metrics:init(), - %% Verify that metrics_backend key exists in the ETS table - Result = ets:lookup(?CONFIG, metrics_backend), - ?assertMatch([{metrics_backend, _}], Result) - end}, - {"get_backend returns the metrics backend module", - fun() -> - hackney_metrics:init(), - Backend = hackney_metrics:get_backend(), - %% Should return a metrics module (atom) - ?assert(is_atom(Backend)) - end}, - {"init uses dummy backend by default", - fun() -> - %% Ensure we're using dummy metrics (default) - application:unset_env(hackney, metrics_backend), - hackney_metrics:init(), - Backend = hackney_metrics:get_backend(), - ?assertEqual(hackney_metrics_dummy, Backend) - end}, - {"dummy backend counter_inc works", - fun() -> - %% counter_inc should not crash - ?assertEqual(ok, hackney_metrics:counter_inc(test_counter, #{host => <<"test">>})) - end}, - {"dummy backend gauge_set works", - fun() -> - %% gauge_set should not crash - ?assertEqual(ok, hackney_metrics:gauge_set(test_gauge, #{pool => default}, 42)) - end}, - {"dummy backend histogram_observe works", - fun() -> - %% histogram_observe should not crash - ?assertEqual(ok, hackney_metrics:histogram_observe(test_histogram, #{host => <<"test">>}, 0.5)) - end} - ]}. - -setup() -> - %% Create the ETS table if it doesn't exist - case ets:info(?CONFIG) of - undefined -> - ets:new(?CONFIG, [named_table, public, set]); - _ -> - ok - end, - ok. - -teardown(_) -> - %% Clean up the ETS table entry - catch ets:delete(?CONFIG, metrics_backend), - ok. diff --git a/test/hackney_middleware_integration_tests.erl b/test/hackney_middleware_integration_tests.erl new file mode 100644 index 00000000..cabb5f6d --- /dev/null +++ b/test/hackney_middleware_integration_tests.erl @@ -0,0 +1,95 @@ +%%% -*- erlang -*- +%%% +%%% This file is part of hackney released under the Apache 2 license. +%%% See the NOTICE for more information. +%%% +%%% End-to-end test: drive a real cowboy server through the hackney +%%% request funnel with a middleware that counts + rewrites headers. +%%% Proves the chain runs at the public `hackney:request/5' entry point. +-module(hackney_middleware_integration_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(PORT, 9981). +-define(PT_COUNT, {?MODULE, count}). + +setup() -> + {ok, _} = application:ensure_all_started(hackney), + {ok, _} = application:ensure_all_started(cowboy), + persistent_term:put(?PT_COUNT, 0), + Dispatch = cowboy_router:compile([ + {'_', [{"/[...]", test_http_resource, []}]} + ]), + {ok, _} = cowboy:start_clear(middleware_test_http, + [{port, ?PORT}], + #{env => #{dispatch => Dispatch}}), + ok. + +cleanup(_) -> + cowboy:stop_listener(middleware_test_http), + persistent_term:erase(?PT_COUNT), + application:unset_env(hackney, middleware), + ok. + +url(Path) -> + <<"http://localhost:", (integer_to_binary(?PORT))/binary, Path/binary>>. + +middleware_integration_test_() -> + {setup, fun setup/0, fun cleanup/1, + [ + fun per_request_chain_fires_on_every_call/0, + fun header_rewrite_reaches_the_server/0, + fun global_env_chain_is_used_when_option_absent/0, + fun short_circuit_skips_the_network/0 + ]}. + +%% ============================================================================ + +per_request_chain_fires_on_every_call() -> + persistent_term:put(?PT_COUNT, 0), + Counter = fun(Req, Next) -> + persistent_term:put(?PT_COUNT, persistent_term:get(?PT_COUNT) + 1), + Next(Req) + end, + Opts = [{middleware, [Counter]}, {pool, false}], + {ok, 200, _, _} = hackney:request(get, url(<<"/get">>), [], <<>>, Opts), + {ok, 200, _, _} = hackney:request(get, url(<<"/get">>), [], <<>>, Opts), + {ok, 200, _, _} = hackney:request(get, url(<<"/get">>), [], <<>>, Opts), + ?assertEqual(3, persistent_term:get(?PT_COUNT)). + +header_rewrite_reaches_the_server() -> + %% Middleware appends a header; test_http_resource echoes the + %% request headers back in the JSON body at /get. + AddHeader = fun(Req, Next) -> + H = maps:get(headers, Req), + Next(Req#{headers := [{<<"x-mw">>, <<"present">>} | H]}) + end, + Opts = [{middleware, [AddHeader]}, {pool, false}], + {ok, 200, _, Body} = + hackney:request(get, url(<<"/get">>), [], <<>>, Opts), + #{<<"headers">> := Headers} = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"present">>, maps:get(<<"x-mw">>, Headers)). + +global_env_chain_is_used_when_option_absent() -> + persistent_term:put(?PT_COUNT, 0), + Counter = fun(Req, Next) -> + persistent_term:put(?PT_COUNT, persistent_term:get(?PT_COUNT) + 1), + Next(Req) + end, + application:set_env(hackney, middleware, [Counter]), + try + {ok, 200, _, _} = + hackney:request(get, url(<<"/get">>), [], <<>>, [{pool, false}]) + after + application:unset_env(hackney, middleware) + end, + ?assertEqual(1, persistent_term:get(?PT_COUNT)). + +short_circuit_skips_the_network() -> + Cache = fun(_Req, _Next) -> {ok, 418, [], <<"from-cache">>} end, + Opts = [{middleware, [Cache]}, {pool, false}], + ?assertEqual({ok, 418, [], <<"from-cache">>}, + hackney:request(get, url(<<"/get">>), [], <<>>, Opts)), + %% Prove the cowboy listener is still alive — no network call was + %% actually attempted. + {ok, 200, _, _} = + hackney:request(get, url(<<"/get">>), [], <<>>, [{pool, false}]). diff --git a/test/hackney_middleware_tests.erl b/test/hackney_middleware_tests.erl new file mode 100644 index 00000000..c094ae0d --- /dev/null +++ b/test/hackney_middleware_tests.erl @@ -0,0 +1,124 @@ +%%% -*- erlang -*- +%%% +%%% This file is part of hackney released under the Apache 2 license. +%%% See the NOTICE for more information. +%%% +-module(hackney_middleware_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(REQ, #{method => get, + url => placeholder, + headers => [], + body => <<>>, + options => []}). + +%% ============================================================================ +%% apply_chain +%% ============================================================================ + +empty_chain_is_identity_test() -> + Terminal = fun(_) -> {ok, 200, [], <<"terminal">>} end, + ?assertEqual({ok, 200, [], <<"terminal">>}, + hackney_middleware:apply_chain([], ?REQ, Terminal)). + +outermost_first_order_test() -> + %% Record order of entry and exit. `[A, B, C]' must run + %% A-pre, B-pre, C-pre, terminal, C-post, B-post, A-post. + Parent = self(), + Mk = fun(Tag) -> + fun(R, Next) -> + Parent ! {pre, Tag}, + Resp = Next(R), + Parent ! {post, Tag}, + Resp + end + end, + Terminal = fun(_) -> Parent ! terminal, {ok, 200, [], <<>>} end, + _ = hackney_middleware:apply_chain([Mk(a), Mk(b), Mk(c)], ?REQ, Terminal), + ?assertEqual([{pre, a}, {pre, b}, {pre, c}, + terminal, + {post, c}, {post, b}, {post, a}], + drain()). + +request_rewrite_is_seen_downstream_test() -> + AddHeader = fun(R, Next) -> + H = maps:get(headers, R), + Next(R#{headers := [{<<"x-test">>, <<"1">>} | H]}) + end, + Terminal = fun(R) -> {ok, 200, maps:get(headers, R), <<>>} end, + {ok, 200, Headers, _} = + hackney_middleware:apply_chain([AddHeader], ?REQ, Terminal), + ?assertEqual([{<<"x-test">>, <<"1">>}], Headers). + +response_rewrite_is_seen_upstream_test() -> + Inner = fun(R, Next) -> + {ok, S, H, _B} = Next(R), + {ok, S, H, <<"rewritten">>} + end, + Terminal = fun(_) -> {ok, 200, [], <<"original">>} end, + ?assertMatch({ok, 200, [], <<"rewritten">>}, + hackney_middleware:apply_chain([Inner], ?REQ, Terminal)). + +short_circuit_does_not_call_terminal_test() -> + Parent = self(), + Cache = fun(_R, _Next) -> {ok, 200, [], <<"cached">>} end, + Terminal = fun(_) -> Parent ! terminal_fired, {ok, 500, [], <<>>} end, + ?assertEqual({ok, 200, [], <<"cached">>}, + hackney_middleware:apply_chain([Cache], ?REQ, Terminal)), + receive + terminal_fired -> ?assert(false, "terminal fired despite short-circuit") + after 10 -> ok + end. + +middleware_crash_propagates_test() -> + Bad = fun(_R, _Next) -> erlang:error(boom) end, + Terminal = fun(_) -> {ok, 200, [], <<>>} end, + ?assertError(boom, + hackney_middleware:apply_chain([Bad], ?REQ, Terminal)). + +error_tuple_flows_back_unchanged_test() -> + Pass = fun(R, Next) -> Next(R) end, + Terminal = fun(_) -> {error, closed} end, + ?assertEqual({error, closed}, + hackney_middleware:apply_chain([Pass], ?REQ, Terminal)). + +%% ============================================================================ +%% resolve_chain +%% ============================================================================ + +resolve_chain_prefers_per_request_option_test() -> + A = fun(R, N) -> N(R) end, + B = fun(R, N) -> N(R) end, + application:set_env(hackney, middleware, [A]), + try + ?assertEqual([B], + hackney_middleware:resolve_chain([{middleware, [B]}])) + after + application:unset_env(hackney, middleware) + end. + +resolve_chain_falls_back_to_app_env_test() -> + A = fun(R, N) -> N(R) end, + application:set_env(hackney, middleware, [A]), + try + ?assertEqual([A], hackney_middleware:resolve_chain([])) + after + application:unset_env(hackney, middleware) + end. + +resolve_chain_defaults_to_empty_test() -> + application:unset_env(hackney, middleware), + ?assertEqual([], hackney_middleware:resolve_chain([])). + +%% ============================================================================ +%% Helpers +%% ============================================================================ + +drain() -> + drain([]). + +drain(Acc) -> + receive + M -> drain([M | Acc]) + after 0 -> lists:reverse(Acc) + end. diff --git a/test/hackney_util_tests.erl b/test/hackney_util_tests.erl index f9533409..21d0986e 100644 --- a/test/hackney_util_tests.erl +++ b/test/hackney_util_tests.erl @@ -202,17 +202,3 @@ privdir_test_() -> ?assert(length(Dir) > 0) end} ]. - -%% mod_metrics/0 test -mod_metrics_test_() -> - {setup, - fun() -> ok end, - fun(_) -> ok end, - [ - {"returns metrics_dummy by default", - fun() -> - Result = hackney_util:mod_metrics(), - %% Default should be metrics_dummy or configured value - ?assert(is_atom(Result)) - end} - ]}. From cc078daba3e7d8c099dd1e2235726cdae47aa8d8 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 16 Apr 2026 08:28:55 +0200 Subject: [PATCH 2/2] fix(middleware): use #hackney_url{} record instead of unknown type --- guides/middleware.md | 2 +- src/hackney_middleware.erl | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/guides/middleware.md b/guides/middleware.md index e2f68de9..608bc8b5 100644 --- a/guides/middleware.md +++ b/guides/middleware.md @@ -7,7 +7,7 @@ no registry, no deps. ```erlang -type request() :: #{method := atom() | binary(), - url := hackney_url:hackney_url(), + url := #hackney_url{}, headers := [{binary(), binary()}], body := term(), options := [term()]}. diff --git a/src/hackney_middleware.erl b/src/hackney_middleware.erl index 3c3f7034..1ce6cd7b 100644 --- a/src/hackney_middleware.erl +++ b/src/hackney_middleware.erl @@ -39,8 +39,10 @@ -export_type([request/0, response/0, next/0, middleware/0]). +-include("hackney_lib.hrl"). + -type request() :: #{method := atom() | binary(), - url := hackney_url:hackney_url(), + url := #hackney_url{}, headers := [{binary(), binary()}], body := term(), options := [term()]}.