Packages
hackney
4.6.1
4.7.2
4.7.1
4.7.0
4.6.1
4.6.0
4.5.2
4.5.1
4.5.0
4.4.5
4.4.3
4.4.2
4.4.1
4.4.0
4.3.0
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.3
4.0.2
4.0.1
4.0.0
3.2.1
3.2.0
3.1.2
3.1.1
3.1.0
3.0.3
3.0.2
3.0.1
3.0.0
retired
2.0.1
2.0.0
2.0.0-beta.1
1.25.0
1.24.1
1.24.0
1.23.0
1.22.0
1.21.0
1.20.1
1.20.0
1.19.1
1.19.0
1.18.2
1.18.1
1.18.0
1.17.4
1.17.3
1.17.2
1.17.1
1.17.0
1.16.0
1.15.2
1.15.1
1.15.0
1.14.3
1.14.2
1.14.0
1.13.0
1.12.1
1.12.0
1.11.0
1.10.1
1.10.0
1.9.0
1.8.6
1.8.5
1.8.4
1.8.3
1.8.2
1.8.0
1.7.1
1.7.0
1.6.6
retired
1.6.5
1.6.4
retired
1.6.3
1.6.2
1.6.1
1.6.0
1.5.7
1.5.6
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.10
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.0
1.1.0
1.0.6
1.0.5
1.0.2
1.0.1
0.15.2
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.1
Simple HTTP client with HTTP/1.1, HTTP/2, and HTTP/3 support
Current section
Files
Jump to
Current section
Files
src/hackney.erl
%%% -*- erlang -*-
%%%
%%% This file is part of hackney released under the Apache 2 license.
%%% See the NOTICE for more information.
%%%
%%% Simplified hackney API using process-per-connection architecture.
%%% Connection handles are now hackney_conn process PIDs.
-module(hackney).
-export([connect/1, connect/2, connect/3, connect/4,
close/1,
peername/1,
peercert/1,
sockname/1,
request/1, request/2, request/3, request/4, request/5,
send_request/2,
cookies/1,
send_body/2, finish_send_body/1, start_response/1,
body/1, body/2, stream_body/1,
setopts/2]).
%% WebSocket API
-export([ws_connect/1, ws_connect/2,
ws_send/2,
ws_recv/1, ws_recv/2,
ws_setopts/2,
ws_close/1, ws_close/2]).
%% WebTransport API
-export([wt_connect/1, wt_connect/2,
wt_send/2,
wt_recv/1, wt_recv/2,
wt_setopts/2,
wt_close/1, wt_close/2,
wt_open_stream/2,
wt_stream_send/3, wt_stream_send/4,
wt_stream_recv/2, wt_stream_recv/3,
wt_close_stream/2,
wt_reset_stream/3,
wt_stop_sending/3,
wt_send_datagram/2,
wt_session_info/1]).
%% HTTP/2 bidirectional (gRPC-style) stream API
-export([h2_open/2, h2_open/3, h2_open/4,
h2_send/2, h2_send/3,
h2_send_trailers/2,
h2_recv/1, h2_recv/2,
h2_consume/2,
h2_setopts/2,
h2_close/1]).
-export([redirect_location/1, location/1]).
-export([get_version/0]).
-export([default_ua/0]).
%% Async streaming
-export([stream_next/1,
stop_async/1,
pause_stream/1,
resume_stream/1]).
-export([parse_proxy_url/1]).
-ifdef(TEST).
-export([get_proxy_env/1, do_get_proxy_env/1]).
-export([get_proxy_config/3]).
-export([check_no_proxy/2]).
-export([start_conn_with_socket/5]).
-endif.
-define(METHOD_TPL(Method),
-export([Method/1, Method/2, Method/3, Method/4])).
-include("hackney_methods.hrl").
-include("hackney.hrl").
-include("hackney_lib.hrl").
-include_lib("hackney_internal.hrl").
-type url() :: #hackney_url{} | binary().
-type conn() :: pid().
-type request_ret() ::
{ok, integer(), list(), binary()} | %% response with body
{ok, integer(), list()} | %% HEAD request
{ok, conn()} | %% async mode or streaming body upload (body = stream)
{error, term()}.
-export_type([url/0, conn/0, request_ret/0]).
%%====================================================================
%% Connection API
%%====================================================================
connect(URL) ->
connect(URL, []).
connect(#hackney_url{}=URL, Options) ->
#hackney_url{transport=Transport,
host=Host,
port=Port} = URL,
connect(Transport, Host, Port, Options);
connect(URL, Options) when is_binary(URL) orelse is_list(URL) ->
connect(hackney_url:parse_url(URL), Options).
%% @doc Connect to a host and return a connection handle (hackney_conn PID).
-spec connect(module(), string(), inet:port_number()) -> {ok, conn()} | {error, term()}.
connect(Transport, Host, Port) ->
connect(Transport, Host, Port, []).
-spec connect(module(), string() | binary(), inet:port_number(), list()) ->
{ok, conn()} | {error, term()}.
connect(Transport, Host, Port, Options) when is_binary(Host) ->
connect(Transport, binary_to_list(Host), Port, Options);
connect(Transport, Host, Port, Options) ->
%% Check if using a pool
UsePool = use_pool(Options),
case UsePool of
false ->
%% Direct connection - start a hackney_conn process
connect_direct(Transport, Host, Port, Options);
_PoolName ->
%% Pool mode with per-host load regulation
connect_pool(Transport, Host, Port, Options)
end.
%% @private Direct connection without pool
connect_direct(Transport, Host, Port, Options) ->
%% Build connect_options including protocols for ALPN
BaseConnectOpts = proplists:get_value(connect_options, Options, []),
Protocols = proplists:get_value(protocols, Options, undefined),
ConnectOpts = case Protocols of
undefined -> BaseConnectOpts;
_ -> [{protocols, Protocols} | BaseConnectOpts]
end,
ConnOpts = #{
host => Host,
port => Port,
transport => Transport,
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
connect_options => ConnectOpts,
ssl_options => proplists:get_value(ssl_options, Options, [])
},
case hackney_conn_sup:start_conn(ConnOpts) of
{ok, ConnPid} ->
case hackney_conn:connect(ConnPid) of
ok ->
{ok, ConnPid};
{error, Reason} ->
stop_conn(ConnPid),
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
%% @private Pool connection with load regulation
%% Flow:
%% 1. For SSL: Check if existing HTTP/3 or HTTP/2 connection can be reused (multiplexing)
%% 2. If HTTP/3 allowed and available (via Alt-Svc), try HTTP/3 first
%% 3. Acquire slot from load_regulation (blocks if at per-host limit)
%% 4. Get TCP connection from pool (always TCP, pool doesn't store SSL)
%% 5. Upgrade to SSL if needed (in-place upgrade)
%% 6. If HTTP/2 negotiated, register for multiplexing
%% Note: load_regulation slot is released when connection is checked in or dies
connect_pool(Transport, Host, Port, Options) ->
PoolHandler = hackney_app:get_app_env(pool_handler, hackney_pool),
%% Check which protocols are allowed (default from application env)
Protocols = proplists:get_value(protocols, Options, hackney_util:default_protocols()),
H3Allowed = lists:member(http3, Protocols),
H2Allowed = lists:member(http2, Protocols),
%% For SSL connections, try multiplexed protocols (HTTP/3 first, then HTTP/2)
case Transport of
hackney_ssl ->
%% Compute the exact TLS handshake options once; their hash keys the
%% shared HTTP/2 connection so requests with different ssl_options
%% never share a connection. FinalSslOpts is passed alongside Options
%% (not inside it) to keep the large CA material out of the pool state.
{FinalSslOpts, TlsKey} = effective_ssl_opts(Host, Options),
Options2 = [{tls_key, TlsKey} | Options],
%% Check HTTP/3 first if allowed
case H3Allowed andalso try_h3_connection(Host, Port, Transport, Options, PoolHandler) of
{ok, H3Pid} ->
{ok, H3Pid};
_ when H2Allowed ->
%% Try HTTP/2 multiplexing
case PoolHandler:checkout_h2(Host, Port, Transport, Options2) of
{ok, H2Pid} ->
%% Verify connection is actually in connected state
%% (OTP 28 on FreeBSD may have timing issues with SSL connections)
case hackney_conn:get_state(H2Pid) of
{ok, connected} ->
{ok, H2Pid};
_ ->
%% Connection not ready, unregister and create new
PoolHandler:unregister_h2(H2Pid, Options2),
connect_pool_new(Transport, Host, Port, Options2, FinalSslOpts, PoolHandler)
end;
none ->
connect_pool_new(Transport, Host, Port, Options2, FinalSslOpts, PoolHandler)
end;
_ ->
%% No multiplexed protocols allowed or available
connect_pool_new(Transport, Host, Port, Options2, FinalSslOpts, PoolHandler)
end;
_ ->
%% Non-SSL, use normal pool
connect_pool_new(Transport, Host, Port, Options, undefined, PoolHandler)
end.
%% @private Build the exact TLS handshake options for a pooled SSL request,
%% plus their memoized pool key hash. Single source of truth: replicates
%% what maybe_upgrade_ssl used to build inline, including the optional
%% protocols entry for ALPN.
effective_ssl_opts(Host, Options) ->
SslOpts = proplists:get_value(ssl_options, Options, []),
SslOpts2 = case proplists:get_value(protocols, Options, undefined) of
undefined -> SslOpts;
Protocols -> [{protocols, Protocols} | SslOpts]
end,
ConnectOpts = proplists:get_value(connect_options, Options, []),
hackney_ssl:effective_opts_and_key(Host, SslOpts2, ConnectOpts).
%% @private Try to get or establish an HTTP/3 connection
try_h3_connection(Host, Port, Transport, Options, PoolHandler) ->
%% Hash the QUIC trust projection once; it keys both the shared H3
%% connection and the 0-RTT ticket cache so requests with differing
%% trust configs never share either.
ConnectOpts = proplists:get_value(connect_options, Options, []),
SslOpts = proplists:get_value(ssl_options, Options, []),
K3 = hackney_ssl:h3_options_key(ConnectOpts, SslOpts),
Options2 = [{h3_tls_key, K3} | Options],
%% Check if HTTP/3 is blocked for this host (negative cache)
case hackney_altsvc:is_h3_blocked(Host, Port) of
true ->
false;
false ->
%% Check if we have an existing HTTP/3 connection
case PoolHandler:checkout_h3(Host, Port, Transport, Options2) of
{ok, H3Pid} ->
%% Verify connection is actually in connected state
case hackney_conn:get_state(H3Pid) of
{ok, connected} ->
{ok, H3Pid};
_ ->
%% Connection not ready, unregister and try new connection
PoolHandler:unregister_h3(H3Pid, Options2),
try_new_h3_connection(Host, Port, Transport, Options2, PoolHandler)
end;
none ->
%% Check Alt-Svc cache for known HTTP/3 endpoint
case hackney_altsvc:lookup(Host, Port) of
{ok, h3, H3Port} ->
%% Alt-Svc says HTTP/3 is available, try connecting
try_new_h3_connection(Host, H3Port, Transport, Options2, PoolHandler);
none ->
%% No Alt-Svc cached, only try H3 if explicitly requested
case lists:member(http3, proplists:get_value(protocols, Options, [])) of
true ->
%% User explicitly wants HTTP/3, try it
try_new_h3_connection(Host, Port, Transport, Options2, PoolHandler);
false ->
false
end
end
end
end.
%% @private Establish a new HTTP/3 connection
try_new_h3_connection(Host, Port, Transport, Options, PoolHandler) ->
%% Start HTTP/3 connection via hackney_conn
ConnectTimeout = proplists:get_value(connect_timeout, Options, 8000),
BaseConnectOpts = proplists:get_value(connect_options, Options, []),
SslOpts = proplists:get_value(ssl_options, Options, []),
%% Forward the caller's connect_options (e.g. {family, inet6}) and resolve the
%% 0-RTT session ticket (explicit option beats the pool cache).
ConnectOpts0 = [{protocols, [http3]} | proplists:delete(protocols, BaseConnectOpts)],
ConnectOpts = maybe_inject_h3_session(ConnectOpts0, SslOpts, Host, Port, Transport,
Options, PoolHandler),
PoolName = proplists:get_value(pool, Options, default),
ConnOpts = #{
host => Host,
port => Port,
transport => Transport,
connect_timeout => ConnectTimeout,
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
connect_options => ConnectOpts,
ssl_options => SslOpts,
pool_name => PoolName,
pool_handler => PoolHandler
},
case hackney_conn_sup:start_conn(ConnOpts) of
{ok, ConnPid} ->
case hackney_conn:connect(ConnPid, ConnectTimeout) of
ok ->
%% Verify it's HTTP/3
try hackney_conn:get_protocol(ConnPid) of
http3 ->
%% Register for multiplexing
PoolHandler:register_h3(Host, Port, Transport, ConnPid, Options),
{ok, ConnPid};
_ ->
%% Not HTTP/3, close and fail
stop_conn(ConnPid),
hackney_altsvc:mark_h3_blocked(Host, Port),
false
catch
_:_ ->
%% Connection terminated before we could check
hackney_altsvc:mark_h3_blocked(Host, Port),
false
end;
{error, _Reason} ->
stop_conn(ConnPid),
hackney_altsvc:mark_h3_blocked(Host, Port),
false
end;
{error, _Reason} ->
hackney_altsvc:mark_h3_blocked(Host, Port),
false
end.
%% @private Resolve the 0-RTT session ticket for a new H3 connection.
%% Precedence: an explicit `session_ticket' in connect_options or ssl_options
%% wins; otherwise, unless `zero_rtt' is disabled, a pool-cached ticket for
%% {Host, Port, Transport} is injected. The pool lookup is guarded so a custom
%% pool handler without the callback degrades to no reuse rather than crashing.
maybe_inject_h3_session(ConnectOpts, SslOpts, Host, Port, Transport, Options, PoolHandler) ->
ZeroRtt = proplists:get_value(zero_rtt, Options, true),
Explicit = proplists:get_value(session_ticket, ConnectOpts,
proplists:get_value(session_ticket, SslOpts, undefined)),
case {ZeroRtt, Explicit} of
{false, _} -> ConnectOpts;
{_, T} when T =/= undefined -> ConnectOpts;
_ ->
case erlang:function_exported(PoolHandler, get_h3_session, 4) of
false -> ConnectOpts;
true ->
case PoolHandler:get_h3_session(Host, Port, Transport, Options) of
{ok, Cached} -> [{session_ticket, Cached} | ConnectOpts];
_ -> ConnectOpts
end
end
end.
connect_pool_new(Transport, Host, Port, Options, FinalSslOpts, PoolHandler) ->
MaxPerHost = proplists:get_value(max_per_host, Options, 50),
CheckoutTimeout = proplists:get_value(checkout_timeout, Options,
proplists:get_value(connect_timeout, Options, 8000)),
%% 1. Acquire per-host slot (blocks with backoff until available)
case hackney_load_regulation:acquire(Host, Port, MaxPerHost, CheckoutTimeout) of
ok ->
%% Slot acquired - now get connection from pool
SslPooling = proplists:get_value(ssl_pooling, Options,
hackney_app:get_app_env(ssl_pooling, false)),
case Transport =:= hackney_ssl andalso SslPooling =:= true
andalso erlang:function_exported(PoolHandler, checkout_ssl, 4) of
true ->
%% Opt-in ssl_pooling: pooled HTTPS/1.1 connections are reused on
%% an exact match of the TLS options hash (tls_key in Options)
connect_pool_ssl(Transport, Host, Port, Options, FinalSslOpts, PoolHandler);
false ->
%% Always checkout as TCP - pool only stores TCP connections
case PoolHandler:checkout(Host, Port, hackney_tcp, Options) of
{ok, _PoolRef, ConnPid} ->
%% Got TCP connection - upgrade to SSL if needed
case maybe_upgrade_ssl(Transport, ConnPid, FinalSslOpts) of
ok ->
%% Check if HTTP/2 was negotiated, register for multiplexing
maybe_register_h2(ConnPid, Host, Port, Transport, Options, PoolHandler),
{ok, ConnPid};
{error, Reason} ->
%% Upgrade failed - release slot and close connection
hackney_load_regulation:release(Host, Port),
stop_conn(ConnPid),
{error, Reason}
end;
{error, Reason} ->
%% Checkout failed - release slot
hackney_load_regulation:release(Host, Port),
{error, Reason}
end
end;
{error, timeout} ->
{error, checkout_timeout}
end.
%% @private SSL-pooling checkout. A `ready' conn is an already-upgraded
%% HTTPS/1.1 connection reused on an exact tls_key match; it was registered
%% for h2 at creation if applicable, so it is not re-registered here. A
%% `needs_upgrade' conn is a TCP connection upgraded with pool_ssl so it can
%% return to the pool at checkin.
connect_pool_ssl(Transport, Host, Port, Options, FinalSslOpts, PoolHandler) ->
case PoolHandler:checkout_ssl(Host, Port, Transport, Options) of
{ok, _PoolRef, ConnPid, ready} ->
{ok, ConnPid};
{ok, _PoolRef, ConnPid, needs_upgrade} ->
case hackney_conn:upgrade_to_ssl(ConnPid, FinalSslOpts,
#{final => true, pool_ssl => true}) of
ok ->
maybe_register_h2(ConnPid, Host, Port, Transport, Options, PoolHandler),
{ok, ConnPid};
{error, Reason} ->
hackney_load_regulation:release(Host, Port),
stop_conn(ConnPid),
{error, Reason}
end;
{error, Reason} ->
hackney_load_regulation:release(Host, Port),
{error, Reason}
end.
%% @private Register HTTP/2 connection for multiplexing if applicable
%% Wrapped in try to handle a race where the connection terminates before the call
maybe_register_h2(ConnPid, Host, Port, Transport, Options, PoolHandler) ->
try hackney_conn:get_protocol(ConnPid) of
http2 ->
%% HTTP/2 negotiated - register for connection sharing
PoolHandler:register_h2(Host, Port, Transport, ConnPid, Options);
http1 ->
ok;
http3 ->
ok
catch
_:_ ->
%% Connection terminated before we could check - ignore
ok
end.
%% @private Upgrade TCP connection to SSL if needed.
%% FinalSslOpts is precomputed by effective_ssl_opts/2 so the handshake uses
%% exactly the options hashed into the pool tls_key.
maybe_upgrade_ssl(hackney_ssl, ConnPid, FinalSslOpts) ->
%% Check if connection is already SSL (e.g., reused SSL connection)
try hackney_conn:is_upgraded_ssl(ConnPid) of
true ->
%% Already SSL, no upgrade needed
ok;
_ ->
%% Upgrade TCP to SSL with ALPN
hackney_conn:upgrade_to_ssl(ConnPid, FinalSslOpts, #{final => true})
catch
_:_ ->
%% Connection terminated, attempt upgrade anyway
hackney_conn:upgrade_to_ssl(ConnPid, FinalSslOpts, #{final => true})
end;
maybe_upgrade_ssl(_, _ConnPid, _FinalSslOpts) ->
%% Not SSL, no upgrade needed
ok.
%% @private Stop a connection, tolerating an already-dead process.
stop_conn(ConnPid) ->
try hackney_conn:stop(ConnPid) catch _:_ -> ok end.
%% @private Signal the websocket process to shut down, ignoring errors.
shutdown_ws(WsPid) ->
try exit(WsPid, shutdown) catch _:_ -> ok end.
%% @doc Close a connection.
-spec close(conn()) -> ok.
close(ConnPid) when is_pid(ConnPid) ->
hackney_conn:stop(ConnPid).
%% @doc Start a connection with a pre-established socket.
%% Used for proxy connections where the tunnel is established first.
%% Socket can be a raw socket or a {Transport, Socket} tuple from proxy modules.
-spec start_conn_with_socket(string(), inet:port_number(), module(),
inet:socket() | {module(), inet:socket()}, list()) ->
{ok, conn()} | {error, term()}.
start_conn_with_socket(Host, Port, _Transport, {SocketTransport, Socket}, Options) ->
%% Handle {Transport, Socket} tuple from proxy modules
%% Use the socket's transport for operations
ActualTransport = normalize_transport(SocketTransport),
start_conn_with_socket_internal(Host, Port, ActualTransport, Socket, Options);
start_conn_with_socket(Host, Port, Transport, Socket, Options) ->
%% Raw socket
ActualTransport = normalize_transport(Transport),
start_conn_with_socket_internal(Host, Port, ActualTransport, Socket, Options).
start_conn_with_socket_internal(Host, Port, Transport, Socket, Options) ->
%% Build connect_options including protocols for ALPN
BaseConnectOpts = proplists:get_value(connect_options, Options, []),
Protocols = proplists:get_value(protocols, Options, undefined),
ConnectOpts = case Protocols of
undefined -> BaseConnectOpts;
_ -> [{protocols, Protocols} | BaseConnectOpts]
end,
%% Check if this is a proxy tunnel connection (should not be reused)
NoReuse = proplists:get_value(no_reuse, Options, false),
ConnOpts = #{
host => Host,
port => Port,
transport => Transport,
socket => Socket,
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
recv_timeout => proplists:get_value(recv_timeout, Options, 5000),
connect_options => ConnectOpts,
ssl_options => proplists:get_value(ssl_options, Options, []),
no_reuse => NoReuse
},
case hackney_conn_sup:start_conn(ConnOpts) of
{ok, ConnPid} ->
{ok, ConnPid};
{error, Reason} ->
{error, Reason}
end.
%% Normalize transport atoms (e.g., ssl -> hackney_ssl, gen_tcp -> hackney_tcp)
normalize_transport(hackney_tcp) -> hackney_tcp;
normalize_transport(hackney_ssl) -> hackney_ssl;
normalize_transport(gen_tcp) -> hackney_tcp;
normalize_transport(ssl) -> hackney_ssl;
normalize_transport(Other) -> Other.
%% @doc Get the remote address and port.
-spec peername(conn()) -> {ok, {inet:ip_address(), inet:port_number()}} | {error, term()}.
peername(ConnPid) when is_pid(ConnPid) ->
hackney_conn:peername(ConnPid).
%% @doc Get the peer SSL certificate.
%% Returns the DER-encoded certificate of the peer, or an error if the connection
%% is not SSL or the certificate is unavailable.
-spec peercert(conn()) -> {ok, binary()} | {error, term()}.
peercert(ConnPid) when is_pid(ConnPid) ->
hackney_conn:peercert(ConnPid).
%% @doc Get the local address and port.
-spec sockname(conn()) -> {ok, {inet:ip_address(), inet:port_number()}} | {error, term()}.
sockname(ConnPid) when is_pid(ConnPid) ->
hackney_conn:sockname(ConnPid).
%% @doc Set socket options.
-spec setopts(conn(), list()) -> ok | {error, term()}.
setopts(ConnPid, Options) when is_pid(ConnPid) ->
hackney_conn:setopts(ConnPid, Options).
%%====================================================================
%% Request API
%%====================================================================
%% @doc Make a request.
-spec request(url()) -> request_ret().
request(URL) ->
request(get, URL).
-spec request(atom() | binary(), url()) -> request_ret().
request(Method, URL) ->
request(Method, URL, [], <<>>, []).
-spec request(atom() | binary(), url(), list()) -> request_ret().
request(Method, URL, Headers) ->
request(Method, URL, Headers, <<>>, []).
-spec request(atom() | binary(), url(), list(), term()) -> request_ret().
request(Method, URL, Headers, Body) ->
request(Method, URL, Headers, Body, []).
%% @doc Make a request.
%%
%% Args:
%% - Method: HTTP method (get, post, put, delete, query, etc.).
%% `query' is the RFC 10008 QUERY method: safe and idempotent, with a
%% request body allowed like post.
%% - URL: Full URL or parsed hackney_url record
%% - Headers: List of headers
%% - Body: Request body (binary, iolist, {form, KVs}, {file, Path}, etc.)
%% - Options: Request options
%%
%% Options:
%% - async: true | once - Receive response asynchronously
%% - stream_to: PID to receive async messages
%% - follow_redirect: Follow redirects automatically
%% - max_redirect: Maximum number of redirects (default 5)
%% - location_trusted: If true, forward auth credentials on cross-host redirects (default false)
%% - pool: Pool name or false for no pooling
%% - connect_timeout: Connection timeout in ms (default 8000)
%% - recv_timeout: Receive timeout in ms (default 5000)
%%
%% Returns:
%% - {ok, Status, Headers, Body}: Success with response body
%% - {ok, Status, Headers}: HEAD request
%% - {ok, Ref}: Async mode - use stream_next/1 to receive messages
%% - {ok, ConnPid}: Streaming body mode (body = stream) - use send_body/2, finish_send_body/1
%% - {error, Reason}: Error
%%
%% Note: The `with_body' option is deprecated and ignored. Body is always returned directly.
-spec request(atom() | binary(), url(), list(), term(), list()) -> request_ret().
request(Method, URL, Headers, Body, Options) when is_binary(URL) orelse is_list(URL) ->
request(Method, hackney_url:parse_url(URL), Headers, Body, Options);
request(Method, #hackney_url{}=URL0, Headers0, Body, Options0) ->
PathEncodeFun = proplists:get_value(path_encode_fun, Options0,
fun hackney_url:pathencode/1),
%% Normalize the URL
URL = hackney_url:normalize(URL0, PathEncodeFun),
?report_trace("request", [{method, Method},
{url, URL},
{headers, Headers0},
{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,
port = Port,
user = User,
password = Password,
path = Path,
qs = Query} = URL,
%% Check for unsupported URL schemes
case Transport of
undefined ->
{error, {unsupported_scheme, Scheme}};
_ ->
request_with_transport(Method, URL, Headers0, Body, Options0,
Transport, Host, Port, User, Password, Path, Query)
end.
%% @private Continue request processing after transport validation
request_with_transport(Method, URL, Headers0, Body, Options0,
Transport, Host, Port, User, Password, Path, Query) ->
Options = case User of
<<>> -> Options0;
_ -> lists:keystore(basic_auth, 1, Options0, {basic_auth, {User, Password}})
end,
%% Build final path
FinalPath = case Query of
<<>> -> Path;
_ -> <<Path/binary, "?", Query/binary>>
end,
%% Check for proxy
case maybe_proxy(Transport, URL#hackney_url.scheme, Host, Port, Options) of
{ok, ConnPid} ->
do_request(ConnPid, Method, FinalPath, Headers0, Body, Options, URL, Host);
{ok, ConnPid, {http_proxy, TargetScheme, TargetHost, TargetPort, ProxyAuth}} ->
%% HTTP proxy mode - use absolute URLs
AbsolutePath = build_absolute_url(TargetScheme, TargetHost, TargetPort, FinalPath),
Headers1 = add_proxy_auth_header(Headers0, ProxyAuth),
do_request(ConnPid, Method, AbsolutePath, Headers1, Body, Options, URL, Host);
Error ->
Error
end.
%% @doc Send a request on an existing connection.
-spec send_request(conn(), {atom(), binary(), list(), term()}) ->
{ok, integer(), list(), conn()} | {ok, integer(), list()} | {error, term()}.
send_request(ConnPid, {Method, Path, Headers, Body}) when is_pid(ConnPid) ->
%% Convert method to binary
MethodBin = hackney_bstr:to_upper(hackney_bstr:to_binary(Method)),
case hackney_conn:request(ConnPid, MethodBin, Path, Headers, Body) of
{ok, Status, RespHeaders} ->
%% HEAD request or no body
case MethodBin of
<<"HEAD">> -> {ok, Status, RespHeaders};
_ -> {ok, Status, RespHeaders, ConnPid}
end;
{error, Reason} ->
{error, Reason}
end.
%%====================================================================
%% Streaming Request Body API
%%====================================================================
%% @doc Send a chunk of the request body.
%% Used when request was initiated with body = stream.
-spec send_body(conn(), iodata()) -> ok | {error, term()}.
send_body(ConnPid, Data) when is_pid(ConnPid) ->
hackney_conn:send_body_chunk(ConnPid, Data).
%% @doc Finish sending the streaming request body.
-spec finish_send_body(conn()) -> ok | {error, term()}.
finish_send_body(ConnPid) when is_pid(ConnPid) ->
hackney_conn:finish_send_body(ConnPid).
%% @doc Start receiving the response after sending the full body.
%% Returns {ok, Status, Headers, ConnPid}.
-spec start_response(conn()) -> {ok, integer(), list(), conn()} | {error, term()}.
start_response(ConnPid) when is_pid(ConnPid) ->
hackney_conn:start_response(ConnPid).
%% @doc Read the full response body after start_response/1.
%% Consumes the response stream and returns it as a single binary.
-spec body(conn()) -> {ok, binary()} | {error, term()}.
body(ConnPid) when is_pid(ConnPid) ->
hackney_conn:body(ConnPid).
%% @doc Same as body/1 with a receive timeout.
-spec body(conn(), timeout()) -> {ok, binary()} | {error, term()}.
body(ConnPid, Timeout) when is_pid(ConnPid) ->
hackney_conn:body(ConnPid, Timeout).
%% @doc Read the response body one chunk at a time after start_response/1.
%% Returns {ok, Chunk} per chunk and done when the body is fully consumed.
-spec stream_body(conn()) -> {ok, binary()} | done | {error, term()}.
stream_body(ConnPid) when is_pid(ConnPid) ->
hackney_conn:stream_body(ConnPid).
%%====================================================================
%% Async Streaming API
%%====================================================================
%% @doc Request next chunk in {async, once} mode.
-spec stream_next(conn()) -> ok.
stream_next(ConnPid) when is_pid(ConnPid) ->
hackney_conn:stream_next(ConnPid).
%% @doc Stop async mode and return to sync mode.
-spec stop_async(conn()) -> ok | {error, term()}.
stop_async(ConnPid) when is_pid(ConnPid) ->
hackney_conn:stop_async(ConnPid).
%% @doc Pause async streaming.
-spec pause_stream(conn()) -> ok.
pause_stream(ConnPid) when is_pid(ConnPid) ->
hackney_conn:pause_stream(ConnPid).
%% @doc Resume async streaming.
-spec resume_stream(conn()) -> ok.
resume_stream(ConnPid) when is_pid(ConnPid) ->
hackney_conn:resume_stream(ConnPid).
%%====================================================================
%% WebSocket API
%%====================================================================
%% @doc Connect to a WebSocket server.
%% URL should use ws:// or wss:// scheme.
%%
%% Options:
%% <ul>
%% <li>active: false | true | once (default false)</li>
%% <li>headers: Extra headers for upgrade request</li>
%% <li>protocols: Sec-WebSocket-Protocol values</li>
%% <li>connect_timeout: Connection timeout in ms (default 8000)</li>
%% <li>recv_timeout: Receive timeout in ms (default infinity)</li>
%% <li>connect_options: Options passed to transport connect</li>
%% <li>ssl_options: Additional SSL options</li>
%% </ul>
%%
%% Returns `{ok, WsPid}' on success, where WsPid is the hackney_ws process.
-spec ws_connect(binary() | string()) -> {ok, pid()} | {error, term()}.
ws_connect(URL) ->
ws_connect(URL, []).
-spec ws_connect(binary() | string(), list()) -> {ok, pid()} | {error, term()}.
ws_connect(URL, Options) when is_binary(URL) orelse is_list(URL) ->
#hackney_url{
transport = Transport,
scheme = Scheme,
host = Host,
port = Port,
path = Path0,
qs = Query
} = hackney_url:parse_url(URL),
%% Validate scheme
case Scheme of
ws -> ok;
wss -> ok;
_ -> error({invalid_websocket_scheme, Scheme})
end,
%% Build path with query string
Path = case Query of
<<>> -> Path0;
_ -> <<Path0/binary, "?", Query/binary>>
end,
%% Get proxy configuration (WebSocket always uses tunnel mode)
ProxyConfig = get_ws_proxy_config(Scheme, Host, Options),
%% Build connection options
WsOpts = #{
host => Host,
port => Port,
transport => Transport,
path => Path,
connect_timeout => proplists:get_value(connect_timeout, Options, 8000),
recv_timeout => proplists:get_value(recv_timeout, Options, infinity),
connect_options => proplists:get_value(connect_options, Options, []),
ssl_options => proplists:get_value(ssl_options, Options, []),
active => proplists:get_value(active, Options, false),
headers => normalize_ws_headers(proplists:get_value(headers, Options, [])),
protocols => proplists:get_value(protocols, Options, []),
proxy => ProxyConfig
},
%% Start WebSocket process and connect
case hackney_ws:start_link(WsOpts) of
{ok, WsPid} ->
Timeout = maps:get(connect_timeout, WsOpts),
try hackney_ws:connect(WsPid, Timeout) of
ok ->
{ok, WsPid};
{error, Reason} ->
shutdown_ws(WsPid),
{error, Reason}
catch
exit:{timeout, _} ->
shutdown_ws(WsPid),
{error, connect_timeout};
exit:{noproc, _} ->
{error, {ws_process_died, noproc}}
end;
{error, Reason} ->
{error, Reason}
end.
%% @doc Send a WebSocket frame.
%% Frame types:
%% - {text, Data} - Text message
%% - {binary, Data} - Binary message
%% - ping | {ping, Data} - Ping frame
%% - pong | {pong, Data} - Pong frame
%% - close | {close, Code, Reason} - Close frame
-spec ws_send(pid(), hackney_ws:ws_frame()) -> ok | {error, term()}.
ws_send(WsPid, Frame) when is_pid(WsPid) ->
hackney_ws:send(WsPid, Frame).
%% @doc Receive a WebSocket frame (passive mode only).
%% Blocks until a frame is received or timeout.
%% Returns {ok, Frame} or {error, Reason}.
-spec ws_recv(pid()) -> {ok, hackney_ws:ws_frame()} | {error, term()}.
ws_recv(WsPid) when is_pid(WsPid) ->
hackney_ws:recv(WsPid).
-spec ws_recv(pid(), timeout()) -> {ok, hackney_ws:ws_frame()} | {error, term()}.
ws_recv(WsPid, Timeout) when is_pid(WsPid) ->
hackney_ws:recv(WsPid, Timeout).
%% @doc Set WebSocket options.
%% Supported options: [{active, true | false | once}]
-spec ws_setopts(pid(), list()) -> ok | {error, term()}.
ws_setopts(WsPid, Opts) when is_pid(WsPid) ->
hackney_ws:setopts(WsPid, Opts).
%% @doc Close WebSocket connection gracefully.
-spec ws_close(pid()) -> ok.
ws_close(WsPid) when is_pid(WsPid) ->
hackney_ws:close(WsPid).
-spec ws_close(pid(), {integer(), binary()}) -> ok.
ws_close(WsPid, {Code, Reason}) when is_pid(WsPid) ->
hackney_ws:close(WsPid, {Code, Reason}).
%%====================================================================
%% WebTransport API
%%====================================================================
%% @doc Connect to a WebTransport server.
%%
%% The URL must use the https:// scheme (wss:// is accepted as an alias so
%% existing ws_* code can switch over by changing only the function name).
%% WebTransport always runs over TLS.
%%
%% Options:
%% <ul>
%% <li>transport: h3 (default) or h2</li>
%% <li>active: false | true | once (default false)</li>
%% <li>headers: extra headers for the CONNECT request</li>
%% <li>connect_timeout: connection timeout in ms (default 8000)</li>
%% <li>recv_timeout: default receive timeout in ms (default infinity)</li>
%% <li>ssl_options: TLS options (verify, cacerts/cacertfile, cert/certfile,
%% key/keyfile)</li>
%% <li>verify: verify_peer (default) | verify_none</li>
%% <li>compat_mode: latest (default) | legacy_browser_compat</li>
%% <li>max_recv_buffer: passive buffer cap in bytes (default 64 MiB)</li>
%% </ul>
%%
%% Returns `{ok, WtPid}' on success, where WtPid is the hackney_wt process.
-spec wt_connect(binary() | string()) -> {ok, pid()} | {error, term()}.
wt_connect(URL) ->
wt_connect(URL, []).
-spec wt_connect(binary() | string(), list()) -> {ok, pid()} | {error, term()}.
wt_connect(URL, Options) when is_binary(URL) orelse is_list(URL) ->
#hackney_url{
scheme = Scheme,
host = Host,
port = Port,
path = Path0,
qs = Query
} = hackney_url:parse_url(URL),
%% WebTransport runs over HTTP/3 or HTTP/2, both TLS-only.
case Scheme of
https -> ok;
wss -> ok;
_ -> error({invalid_webtransport_scheme, Scheme})
end,
Path = case Query of
<<>> -> Path0;
_ -> <<Path0/binary, "?", Query/binary>>
end,
Headers = normalize_ws_headers(proplists:get_value(headers, Options, [])),
%% Mirror the WebSocket GHSA-f9vr guard: reject CR/LF/NUL in the request
%% path or in any caller-supplied header before it reaches the wire.
case valid_wt_fields(Host, Path, Headers) of
ok ->
Transport = proplists:get_value(transport, Options, h3),
ConnectTimeout = proplists:get_value(connect_timeout, Options, 8000),
WtOpts = #{
host => Host,
port => Port,
path => Path,
transport => Transport,
connect_opts => build_wt_connect_opts(Options, Headers),
connect_timeout => ConnectTimeout,
recv_timeout => proplists:get_value(recv_timeout, Options, infinity),
active => proplists:get_value(active, Options, false),
max_recv_buffer => proplists:get_value(max_recv_buffer, Options, 16#4000000)
},
case hackney_wt:start_link(WtOpts) of
{ok, WtPid} ->
try hackney_wt:connect(WtPid, ConnectTimeout) of
ok ->
{ok, WtPid};
{error, Reason} ->
shutdown_wt(WtPid),
{error, Reason}
catch
exit:{noproc, _} ->
{error, {wt_process_died, noproc}}
end;
{error, Reason} ->
{error, Reason}
end;
{error, _} = Err ->
Err
end.
%% @doc Send on a WebTransport connection.
%% Frame forms: `{text, Data}', `{binary, Data}', `Data' (write to the
%% default stream); `{datagram, Data}'; `{stream, StreamId, Data}' or
%% `{stream, StreamId, Data, fin|nofin}'.
-spec wt_send(pid(), hackney_wt:wt_frame()) -> ok | {error, term()}.
wt_send(WtPid, Frame) when is_pid(WtPid) ->
hackney_wt:send(WtPid, Frame).
%% @doc Receive the next message on the default channel (passive mode).
-spec wt_recv(pid()) -> {ok, hackney_wt:wt_msg()} | {error, term()}.
wt_recv(WtPid) when is_pid(WtPid) ->
hackney_wt:recv(WtPid).
-spec wt_recv(pid(), timeout()) -> {ok, hackney_wt:wt_msg()} | {error, term()}.
wt_recv(WtPid, Timeout) when is_pid(WtPid) ->
hackney_wt:recv(WtPid, Timeout).
%% @doc Set WebTransport options. Supported: [{active, true | false | once}]
-spec wt_setopts(pid(), list()) -> ok | {error, term()}.
wt_setopts(WtPid, Opts) when is_pid(WtPid) ->
hackney_wt:setopts(WtPid, Opts).
%% @doc Close a WebTransport session gracefully.
-spec wt_close(pid()) -> ok.
wt_close(WtPid) when is_pid(WtPid) ->
hackney_wt:close(WtPid).
-spec wt_close(pid(), {non_neg_integer(), binary()}) -> ok.
wt_close(WtPid, {Code, Reason}) when is_pid(WtPid) ->
hackney_wt:close(WtPid, {Code, Reason}).
%% @doc Open a new stream multiplexed over the session.
-spec wt_open_stream(pid(), bidi | uni) -> {ok, non_neg_integer()} | {error, term()}.
wt_open_stream(WtPid, Type) when is_pid(WtPid) ->
hackney_wt:open_stream(WtPid, Type).
%% @doc Write to a stream (no FIN).
-spec wt_stream_send(pid(), non_neg_integer(), iodata()) -> ok | {error, term()}.
wt_stream_send(WtPid, StreamId, Data) when is_pid(WtPid) ->
hackney_wt:stream_send(WtPid, StreamId, Data).
%% @doc Write to a stream, optionally closing the write side (FIN).
-spec wt_stream_send(pid(), non_neg_integer(), iodata(), fin | nofin) -> ok | {error, term()}.
wt_stream_send(WtPid, StreamId, Data, Fin) when is_pid(WtPid) ->
hackney_wt:stream_send(WtPid, StreamId, Data, Fin).
%% @doc Receive the next chunk on a stream (passive mode).
-spec wt_stream_recv(pid(), non_neg_integer()) -> hackney_wt:stream_msg().
wt_stream_recv(WtPid, StreamId) when is_pid(WtPid) ->
hackney_wt:stream_recv(WtPid, StreamId).
-spec wt_stream_recv(pid(), non_neg_integer(), timeout()) -> hackney_wt:stream_msg().
wt_stream_recv(WtPid, StreamId, Timeout) when is_pid(WtPid) ->
hackney_wt:stream_recv(WtPid, StreamId, Timeout).
%% @doc Close a stream gracefully (send FIN).
-spec wt_close_stream(pid(), non_neg_integer()) -> ok | {error, term()}.
wt_close_stream(WtPid, StreamId) when is_pid(WtPid) ->
hackney_wt:close_stream(WtPid, StreamId).
%% @doc Abruptly terminate a stream with an error code.
-spec wt_reset_stream(pid(), non_neg_integer(), non_neg_integer()) -> ok | {error, term()}.
wt_reset_stream(WtPid, StreamId, ErrorCode) when is_pid(WtPid) ->
hackney_wt:reset_stream(WtPid, StreamId, ErrorCode).
%% @doc Ask the peer to stop sending on a stream.
-spec wt_stop_sending(pid(), non_neg_integer(), non_neg_integer()) -> ok | {error, term()}.
wt_stop_sending(WtPid, StreamId, ErrorCode) when is_pid(WtPid) ->
hackney_wt:stop_sending(WtPid, StreamId, ErrorCode).
%% @doc Send an unreliable datagram.
-spec wt_send_datagram(pid(), iodata()) -> ok | {error, term()}.
wt_send_datagram(WtPid, Data) when is_pid(WtPid) ->
hackney_wt:send_datagram(WtPid, Data).
%% @doc Return WebTransport session information.
-spec wt_session_info(pid()) -> {ok, map()} | {error, term()}.
wt_session_info(WtPid) when is_pid(WtPid) ->
hackney_wt:session_info(WtPid).
%% @private Signal the WebTransport process to shut down, ignoring errors.
shutdown_wt(WtPid) ->
try exit(WtPid, shutdown) catch _:_ -> ok end.
%%====================================================================
%% HTTP/2 bidirectional (gRPC-style) stream API
%%====================================================================
%% @doc Open a full-duplex HTTP/2 stream (gRPC-style bidirectional streaming).
%% Establishes a dedicated HTTP/2 connection (ALPN, so an https URL) and opens
%% one stream on it. Returns a pid driven with h2_send/h2_recv etc. The method
%% defaults to POST.
%%
%% Options: connect_timeout, recv_timeout, connect_options, ssl_options,
%% {flow_control, auto | manual}, {active, true | false | once},
%% {max_recv_buffer, bytes | infinity}.
-spec h2_open(binary() | string(), list()) -> {ok, pid()} | {error, term()}.
h2_open(URL, Opts) ->
h2_open(post, URL, [], Opts).
-spec h2_open(binary() | string(), list(), list()) -> {ok, pid()} | {error, term()}.
h2_open(URL, Headers, Opts) ->
h2_open(post, URL, Headers, Opts).
-spec h2_open(atom() | binary() | string(), binary() | string(), list(), list()) ->
{ok, pid()} | {error, term()}.
h2_open(Method, URL, Headers, Opts) ->
#hackney_url{
transport = Transport,
scheme = Scheme,
host = Host,
port = Port,
path = Path0,
qs = Query
} = hackney_url:parse_url(URL),
case Transport of
hackney_ssl ->
Path = case Query of
<<>> -> Path0;
_ -> <<Path0/binary, "?", Query/binary>>
end,
H2Opts = #{
method => h2_method_bin(Method),
host => Host,
port => Port,
transport => Transport,
path => Path,
headers => Headers,
connect_timeout => proplists:get_value(connect_timeout, Opts, 8000),
recv_timeout => proplists:get_value(recv_timeout, Opts, infinity),
connect_options => proplists:get_value(connect_options, Opts, []),
ssl_options => proplists:get_value(ssl_options, Opts, []),
flow_control => proplists:get_value(flow_control, Opts, auto),
active => proplists:get_value(active, Opts, false),
max_recv_buffer => proplists:get_value(max_recv_buffer, Opts, 16#4000000)
},
case hackney_h2_stream:start_link(H2Opts) of
{ok, Pid} ->
Timeout = maps:get(connect_timeout, H2Opts),
try hackney_h2_stream:connect(Pid, Timeout) of
ok ->
{ok, Pid};
{error, Reason} ->
shutdown_h2(Pid),
{error, Reason}
catch
exit:{timeout, _} ->
shutdown_h2(Pid),
{error, connect_timeout};
exit:{noproc, _} ->
{error, {h2_process_died, noproc}}
end;
{error, Reason} ->
{error, Reason}
end;
_ ->
{error, {scheme_not_supported, Scheme}}
end.
%% @doc Send a DATA frame on the stream (no END_STREAM).
-spec h2_send(pid(), iodata()) -> ok | {error, term()}.
h2_send(Pid, Data) when is_pid(Pid) ->
hackney_h2_stream:send(Pid, Data).
%% @doc Send a DATA frame, optionally half-closing the send side (`fin').
-spec h2_send(pid(), iodata(), fin | nofin) -> ok | {error, term()}.
h2_send(Pid, Data, Fin) when is_pid(Pid) ->
hackney_h2_stream:send(Pid, Data, Fin).
%% @doc Send trailing HEADERS, half-closing the send side (gRPC trailers).
-spec h2_send_trailers(pid(), list()) -> ok | {error, term()}.
h2_send_trailers(Pid, Trailers) when is_pid(Pid) ->
hackney_h2_stream:send_trailers(Pid, Trailers).
%% @doc Receive the next inbound message: {response, Status, Headers} |
%% {data, Data} | {trailers, Trailers} | done. After done, returns
%% {error, closed}. Passive mode only.
-spec h2_recv(pid()) -> {ok, hackney_h2_stream:h2_msg()} | {error, term()}.
h2_recv(Pid) when is_pid(Pid) ->
hackney_h2_stream:recv(Pid).
-spec h2_recv(pid(), timeout()) -> {ok, hackney_h2_stream:h2_msg()} | {error, term()}.
h2_recv(Pid, Timeout) when is_pid(Pid) ->
hackney_h2_stream:recv(Pid, Timeout).
%% @doc Acknowledge N consumed bytes (manual flow control only).
-spec h2_consume(pid(), non_neg_integer()) -> ok | {error, term()}.
h2_consume(Pid, NBytes) when is_pid(Pid) ->
hackney_h2_stream:consume(Pid, NBytes).
%% @doc Set options. Supported: [{active, true | false | once}].
-spec h2_setopts(pid(), list()) -> ok | {error, term()}.
h2_setopts(Pid, Opts) when is_pid(Pid) ->
hackney_h2_stream:setopts(Pid, Opts).
%% @doc Cancel the stream and tear down its connection.
-spec h2_close(pid()) -> ok.
h2_close(Pid) when is_pid(Pid) ->
hackney_h2_stream:close(Pid).
%% @private Normalize an HTTP method to an uppercase binary.
h2_method_bin(M) when is_binary(M) -> M;
h2_method_bin(M) when is_atom(M) -> list_to_binary(string:to_upper(atom_to_list(M)));
h2_method_bin(M) when is_list(M) -> list_to_binary(string:to_upper(M)).
%% @private Signal the HTTP/2 stream process to shut down, ignoring errors.
shutdown_h2(Pid) ->
try exit(Pid, shutdown) catch _:_ -> ok end.
%% @private Reject CR/LF/NUL in the authority, request path, or any
%% caller-supplied header used in the WebTransport CONNECT request
%% (GHSA-f9vr analog).
valid_wt_fields(Host, Path, Headers) ->
Fields = [Host, Path | lists:flatmap(fun({N, V}) -> [N, V] end, Headers)],
case lists:any(fun has_ctl_bytes/1, Fields) of
true -> {error, invalid_handshake_header};
false -> ok
end.
has_ctl_bytes(Bin) when is_binary(Bin) ->
binary:match(Bin, [<<"\r">>, <<"\n">>, <<0>>]) =/= nomatch;
has_ctl_bytes(L) when is_list(L) ->
has_ctl_bytes(iolist_to_binary(L));
has_ctl_bytes(_) ->
false.
%% @private Translate hackney connect options into a webtransport:connect/4
%% options map. Honours a caller-supplied CA (cacerts/cacertfile); when
%% verifying with no CA given, fall back to the bundled certifi store.
build_wt_connect_opts(Options, Headers) ->
SslOpts = proplists:get_value(ssl_options, Options, []),
Verify = proplists:get_value(verify, Options,
proplists:get_value(verify, SslOpts, verify_peer)),
Base = #{headers => Headers, verify => Verify},
Base1 = case proplists:get_value(compat_mode, Options) of
undefined -> Base;
CompatMode -> Base#{compat_mode => CompatMode}
end,
Base2 = wt_ca_opts(Base1, SslOpts, Verify),
wt_cert_opts(Base2, SslOpts).
%% @private CA trust store selection.
wt_ca_opts(Base, SslOpts, Verify) ->
case {proplists:get_value(cacertfile, SslOpts),
proplists:get_value(cacerts, SslOpts)} of
{undefined, undefined} when Verify =:= verify_peer ->
Base#{cacerts => certifi:cacerts()};
{undefined, undefined} ->
Base;
{CAFile, _} when CAFile =/= undefined ->
Base#{cacertfile => CAFile};
{_, CACerts} ->
Base#{cacerts => CACerts}
end.
%% @private Optional client certificate / key.
wt_cert_opts(Base, SslOpts) ->
Base1 = case {proplists:get_value(certfile, SslOpts),
proplists:get_value(cert, SslOpts)} of
{CertFile, _} when CertFile =/= undefined -> Base#{certfile => CertFile};
{_, Cert} when Cert =/= undefined -> Base#{cert => Cert};
_ -> Base
end,
case {proplists:get_value(keyfile, SslOpts),
proplists:get_value(key, SslOpts)} of
{KeyFile, _} when KeyFile =/= undefined -> Base1#{keyfile => KeyFile};
{_, Key} when Key =/= undefined -> Base1#{key => Key};
_ -> Base1
end.
%% @private Normalize WebSocket headers to {binary(), binary()} format
normalize_ws_headers(Headers) ->
[{hackney_bstr:to_binary(Name), hackney_bstr:to_binary(Value)}
|| {Name, Value} <- Headers].
%% @private Get proxy configuration for WebSocket.
%% WebSocket always uses tunnel mode (CONNECT or SOCKS5), never simple HTTP proxy.
get_ws_proxy_config(Scheme, Host, Options) ->
%% Map ws/wss to http/https for proxy env var lookup
HttpScheme = case Scheme of
ws -> http;
wss -> https
end,
case get_proxy_config(HttpScheme, Host, Options) of
false ->
false;
{http, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport} ->
%% Simple HTTP proxy - convert to CONNECT tunnel for WebSocket
{connect, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport};
{connect, _, _, _, _} = Config ->
Config;
{socks5, _, _, _, _} = Config ->
Config
end.
%%====================================================================
%% Helpers
%%====================================================================
%% @doc Parse cookies from response headers.
-spec cookies(list()) -> list().
cookies(Headers) ->
lists:foldl(fun({K, V}, Acc) ->
case hackney_bstr:to_lower(K) of
<<"set-cookie">> ->
case hackney_cookie:parse_cookie(V) of
{error, _} -> Acc;
[{Name, _} | _]=Cookie ->
[{Name, Cookie} | Acc]
end;
_ ->
Acc
end
end, [], Headers).
%% @doc Get redirect location from headers.
redirect_location(Headers) when is_list(Headers) ->
redirect_location(hackney_headers:from_list(Headers));
redirect_location(Headers) ->
hackney_headers:get_value(<<"location">>, Headers).
%% @doc Get the final URL after following redirects.
%% First checks the stored location (set after redirects),
%% then falls back to the Location header from the last response.
-spec location(conn()) -> binary() | undefined.
location(ConnPid) when is_pid(ConnPid) ->
case hackney_conn:get_location(ConnPid) of
undefined ->
%% No stored location, check response headers
case hackney_conn:response_headers(ConnPid) of
undefined -> undefined;
Headers -> redirect_location(Headers)
end;
Location ->
Location
end.
%%====================================================================
%% Internal functions
%%====================================================================
do_request(ConnPid, Method, Path, Headers0, Body, Options, URL, _Host) ->
%% Build headers
Headers1 = hackney_headers:new(Headers0),
Headers2 = add_host_header(URL, Headers1),
%% Add default headers (User-Agent, Authorization, Cookies)
Headers3 = add_default_headers(Headers2, Options, URL),
%% Check for async mode
Async = proplists:get_value(async, Options, false),
StreamTo = proplists:get_value(stream_to, Options, self()),
%% DEPRECATED: The with_body option is now ignored.
%% Body is always returned directly for consistent behavior between HTTP/1.1 and HTTP/2.
%% For incremental body streaming, use async mode instead.
_ = proplists:get_value(with_body, Options, true), %% Read but ignored
WithBody = true, %% Always return body directly
FollowRedirect = proplists:get_value(follow_redirect, Options, false),
MaxRedirect = proplists:get_value(max_redirect, Options, 5),
RedirectCount = proplists:get_value(redirect_count, Options, 0),
%% Convert method to binary
MethodBin = hackney_bstr:to_upper(hackney_bstr:to_binary(Method)),
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.
sync_request_with_redirect(ConnPid, Method, Path, Headers, Body, WithBody, Options, URL,
FollowRedirect, MaxRedirect, RedirectCount) ->
%% Handle body encoding
{FinalHeaders, FinalBody} = encode_body(Headers, Body, Options),
HeadersList = hackney_headers:to_list(FinalHeaders),
%% Check if this is a streaming body request
case FinalBody of
stream ->
%% For streaming body, just send headers and return immediately
case hackney_conn:send_request_headers(ConnPid, Method, Path, HeadersList) of
ok -> {ok, ConnPid};
{error, Reason} -> {error, Reason}
end;
_ ->
sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
WithBody, Options, URL, FollowRedirect, MaxRedirect, RedirectCount)
end.
sync_request_with_redirect_body(ConnPid, Method, Path, HeadersList, FinalBody,
WithBody, Options, URL, FollowRedirect, MaxRedirect, RedirectCount) ->
%% Extract request options for 1xx informational responses and auto_decompress
ReqOpts0 = case proplists:get_value(inform_fun, Options) of
undefined -> [];
InformFun -> [{inform_fun, InformFun}]
end,
ReqOpts1 = case proplists:get_value(auto_decompress, Options, false) of
true -> [{auto_decompress, true} | ReqOpts0];
false -> ReqOpts0
end,
%% Pass recv_timeout through to the connection so it's applied per-request
ReqOpts = case proplists:get_value(recv_timeout, Options) of
undefined -> ReqOpts1;
RecvTimeout -> [{recv_timeout, RecvTimeout} | ReqOpts1]
end,
case hackney_conn:request(ConnPid, Method, Path, HeadersList, FinalBody, infinity, ReqOpts) of
%% HTTP/2 returns body directly - handle 4-tuple first
{ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 303; Status =:= 307; Status =:= 308 ->
%% HTTP/2 redirect status
case FollowRedirect of
true when RedirectCount < MaxRedirect ->
follow_redirect(ConnPid, Method, FinalBody, WithBody, Options, URL,
RespHeaders, Status, MaxRedirect, RedirectCount);
true ->
{error, {max_redirect, RedirectCount}};
false ->
{ok, Status, RespHeaders, RespBody}
end;
{ok, Status, RespHeaders, RespBody} ->
%% HTTP/2 response with body - already have body
case Method of
<<"HEAD">> ->
{ok, Status, RespHeaders};
_ ->
{ok, Status, RespHeaders, RespBody}
end;
%% HTTP/1.1 returns 3-tuple, body fetched separately
{ok, Status, RespHeaders} when Status >= 301, Status =< 303; Status =:= 307; Status =:= 308 ->
%% Redirect status
case FollowRedirect of
true when RedirectCount < MaxRedirect ->
%% Skip the body if any
_ = hackney_conn:body(ConnPid),
%% Follow redirect
follow_redirect(ConnPid, Method, FinalBody, WithBody, Options, URL,
RespHeaders, Status, MaxRedirect, RedirectCount);
true ->
{error, {max_redirect, RedirectCount}};
false ->
%% Return the redirect response with body
case hackney_conn:body(ConnPid) of
{ok, RespBody} ->
{ok, Status, RespHeaders, RespBody};
{error, Reason} ->
{error, Reason}
end
end;
{ok, Status, RespHeaders} ->
case Method of
<<"HEAD">> ->
%% HEAD responses have no body - call body() to trigger auto-release
%% (body returns immediately for HEAD with empty response)
_ = hackney_conn:body(ConnPid),
{ok, Status, RespHeaders};
_ ->
%% Always fetch body for consistent response format
case hackney_conn:body(ConnPid) of
{ok, RespBody} ->
%% Body read - connection auto-released to pool
{ok, Status, RespHeaders, RespBody};
{error, Reason} ->
{error, Reason}
end
end;
{error, Reason} ->
{error, Reason}
end.
follow_redirect(ConnPid, Method, Body, WithBody, Options, CurrentURL, RespHeaders, Status,
MaxRedirect, RedirectCount) ->
%% Get the Location header
Location = redirect_location(RespHeaders),
case Location of
undefined ->
{error, no_location_header};
LocationBin ->
%% Parse the new URL (could be relative or absolute)
NewURL = resolve_redirect_url(CurrentURL, LocationBin),
%% Get the full URL as a binary for storing
FinalLocation = hackney_url:unparse_url(NewURL),
%% Determine method for redirect (301, 302, 303 -> GET, 307, 308 -> same method)
NewMethod = case Status of
S when S =:= 301; S =:= 302; S =:= 303 ->
<<"GET">>;
_ ->
Method
end,
NewBody = case NewMethod of
<<"GET">> -> <<>>;
_ -> Body
end,
%% Make new request to the redirect URL
%% Remove old redirect_count and add incremented one
Options1 = proplists:delete(redirect_count, Options),
%% CVE-2018-1000007: Strip sensitive auth options when redirecting to different host
%% unless force_redirect is set (similar to curl's --location-trusted)
Options2 = maybe_strip_auth_on_redirect(CurrentURL, NewURL, Options1),
case request(NewMethod, NewURL, [], NewBody,
[{follow_redirect, true}, {max_redirect, MaxRedirect},
{redirect_count, RedirectCount + 1}, {with_body, WithBody} | Options2]) of
{ok, Status2, Headers2, Body2} ->
%% Store the final location in the connection
hackney_conn:set_location(ConnPid, FinalLocation),
{ok, Status2, Headers2, Body2};
{ok, Status2, Headers2} ->
%% Store the final location in the connection
hackney_conn:set_location(ConnPid, FinalLocation),
{ok, Status2, Headers2};
Error ->
Error
end
end.
resolve_redirect_url(CurrentURL, Location) when is_binary(Location) ->
case Location of
<<"http://", _/binary>> -> hackney_url:parse_url(Location);
<<"https://", _/binary>> -> hackney_url:parse_url(Location);
<<"ws://", _/binary>> -> hackney_url:parse_url(Location);
<<"wss://", _/binary>> -> hackney_url:parse_url(Location);
<<"//", _/binary>> ->
%% Network-path reference (RFC 3986 Section 4.2)
%% Use the current scheme with the new authority and path
Scheme = CurrentURL#hackney_url.scheme,
SchemePrefix = atom_to_binary(Scheme, utf8),
hackney_url:parse_url(<<SchemePrefix/binary, ":", Location/binary>>);
<<"/", _/binary>> ->
%% Absolute-path reference - parse query string from Location
{Path, Qs} = parse_path_qs(Location),
CurrentURL#hackney_url{path = Path, qs = Qs};
_ ->
%% Relative-path reference (RFC 3986 Section 5.2.3)
%% Parse query string from Location first
{RelPath, Qs} = parse_path_qs(Location),
CurrentPath = CurrentURL#hackney_url.path,
NewPath = merge_paths(CurrentPath, RelPath),
CurrentURL#hackney_url{path = NewPath, qs = Qs}
end.
%% @private Parse path and query string from a path-like string
parse_path_qs(PathLike) ->
case binary:split(PathLike, <<"?">>) of
[Path] -> {Path, <<>>};
[Path, Qs] -> {Path, Qs}
end.
%% @private Merge a relative path with a base path per RFC 3986 Section 5.2.3
merge_paths(BasePath, RelPath) when is_binary(BasePath), is_binary(RelPath) ->
case BasePath of
<<>> ->
%% Empty base path - prepend /
<<"/", RelPath/binary>>;
<<"/">> ->
%% Root path - just prepend /
<<"/", RelPath/binary>>;
_ ->
%% Get directory part of base path (everything up to and including last /)
BaseDir = base_directory(BasePath),
iolist_to_binary([BaseDir, RelPath])
end.
%% @private Get the directory part of a path (up to and including last /)
%% Example: "/a/b/c" -> "/a/b/", "/a/b/" -> "/a/b/", "/" -> "/", "" -> ""
base_directory(Path) ->
case find_last_slash(Path) of
-1 -> <<>>;
Pos -> binary:part(Path, 0, Pos + 1)
end.
%% @private Find the position of the last "/" in a binary
find_last_slash(Bin) ->
find_last_slash(Bin, byte_size(Bin) - 1).
find_last_slash(_Bin, Pos) when Pos < 0 -> -1;
find_last_slash(Bin, Pos) ->
case binary:at(Bin, Pos) of
$/ -> Pos;
_ -> find_last_slash(Bin, Pos - 1)
end.
%% @private Strip sensitive auth options when redirecting to a different host.
%% This prevents credential leakage per CVE-2018-1000007.
%% Use location_trusted option to allow forwarding auth to different hosts (like curl's --location-trusted).
maybe_strip_auth_on_redirect(CurrentURL, NewURL, Options) ->
LocationTrusted = proplists:get_value(location_trusted, Options, false),
case LocationTrusted of
true ->
%% User explicitly allows forwarding auth to different hosts
Options;
false ->
%% Check if host changed
CurrentHost = CurrentURL#hackney_url.host,
NewHost = NewURL#hackney_url.host,
case CurrentHost =:= NewHost of
true ->
%% Same host - keep auth options
Options;
false ->
%% Different host - strip sensitive options
lists:filter(fun
({basic_auth, _}) -> false;
({cookie, _}) -> false;
(_) -> true
end, Options)
end
end.
async_request(ConnPid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, Options) ->
%% Handle body encoding
{FinalHeaders, FinalBody} = encode_body(Headers, Body, []),
HeadersList = hackney_headers:to_list(FinalHeaders),
%% Build ReqOpts for recv_timeout (fix for issue #832)
ReqOpts = case proplists:get_value(recv_timeout, Options) of
undefined -> [];
RecvTimeout -> [{recv_timeout, RecvTimeout}]
end,
%% Note: Issue #646 - ownership transfer to StreamTo (when different from caller)
%% is handled atomically inside hackney_conn:do_request_async
case hackney_conn:request_async(ConnPid, Method, Path, HeadersList, FinalBody, AsyncMode, StreamTo, FollowRedirect, ReqOpts) of
{ok, Ref} ->
{ok, Ref};
{error, Reason} ->
{error, Reason}
end.
encode_body(Headers, <<>>, _Options) ->
{Headers, <<>>};
encode_body(Headers, [], _Options) ->
{Headers, <<>>};
encode_body(Headers, {form, KVs}, _Options) ->
{CLen, CType, EncodedBody} = encode_form(KVs),
Headers1 = hackney_headers:store(<<"Content-Type">>, CType, Headers),
Headers2 = hackney_headers:store(<<"Content-Length">>, integer_to_binary(CLen), Headers1),
{Headers2, EncodedBody};
encode_body(Headers, {multipart, Parts}, _Options) ->
%% Encode multipart body
Boundary = hackney_multipart:boundary(),
{MpBody, MpSize} = hackney_multipart:encode_form(Parts, Boundary),
%% Add Content-Type with boundary
ContentType = <<"multipart/form-data; boundary=", Boundary/binary>>,
Headers1 = hackney_headers:store(<<"Content-Type">>, ContentType, Headers),
Headers2 = hackney_headers:store(<<"Content-Length">>, integer_to_binary(MpSize), Headers1),
{Headers2, MpBody};
encode_body(Headers, Body, _Options) when is_binary(Body) ->
%% Add Content-Length if not present
Headers1 = case hackney_headers:get_value(<<"content-length">>, Headers) of
undefined ->
hackney_headers:store(<<"Content-Length">>, integer_to_binary(byte_size(Body)), Headers);
_ ->
Headers
end,
%% Add default Content-Type if not present (RFC 7231 recommends application/octet-stream)
{_, Headers2} = hackney_headers:store_new(<<"Content-Type">>, <<"application/octet-stream">>, Headers1),
{Headers2, Body};
encode_body(Headers, Body, _Options) when is_list(Body) ->
Bin = iolist_to_binary(Body),
encode_body(Headers, Bin, _Options);
encode_body(Headers, Body, _Options) ->
{Headers, Body}.
%% @doc Encode form data as application/x-www-form-urlencoded
encode_form(KVs) ->
Lines = hackney_url:qs(KVs),
CType = <<"application/x-www-form-urlencoded; charset=utf-8">>,
{byte_size(Lines), CType, Lines}.
add_host_header(#hackney_url{transport=Transport, netloc=Netloc}, Headers) ->
HostValue = case Transport of
hackney_local_tcp -> hackney_url:urlencode(Netloc);
_ -> Netloc
end,
{_, Headers1} = hackney_headers:store_new(<<"Host">>, HostValue, Headers),
Headers1.
%% Add default headers: User-Agent, Authorization (basic auth), Cookies
add_default_headers(Headers, Options, URL) ->
%% Add User-Agent
{_, Headers1} = hackney_headers:store_new(<<"User-Agent">>, default_ua(), Headers),
%% Add basic auth if present
Headers2 = case proplists:get_value(basic_auth, Options) of
undefined ->
Headers1;
{User, Pwd} ->
%% Check if basic auth over HTTP is allowed
Transport = URL#hackney_url.transport,
AllowInsecureAuth = proplists:get_value(insecure_basic_auth, Options,
hackney_app:get_app_env(insecure_basic_auth, true)),
case {Transport, AllowInsecureAuth} of
{hackney_ssl, _} ->
%% HTTPS - always safe
add_basic_auth_header(User, Pwd, Headers1);
{_, true} ->
%% HTTP with explicit bypass
add_basic_auth_header(User, Pwd, Headers1);
{_, false} ->
erlang:error({insecure_basic_auth,
"Basic authentication over HTTP is insecure. "
"Use HTTPS, add {insecure_basic_auth, true} option, or set "
"application:set_env(hackney, insecure_basic_auth, true) to bypass this check."})
end
end,
%% Add Accept-Encoding if auto_decompress is enabled (issue #155)
Headers3 = case proplists:get_value(auto_decompress, Options, false) of
true ->
{_, H} = hackney_headers:store_new(<<"Accept-Encoding">>, <<"gzip, deflate">>, Headers2),
H;
false ->
Headers2
end,
%% Add cookies if present
case proplists:get_value(cookie, Options, []) of
[] -> Headers3;
Cookies -> add_cookies_header(Cookies, Headers3)
end.
add_basic_auth_header(User, Pwd, Headers) ->
User1 = hackney_bstr:to_binary(User),
Pwd1 = hackney_bstr:to_binary(Pwd),
Credentials = base64:encode(<<User1/binary, ":", Pwd1/binary>>),
hackney_headers:store(<<"Authorization">>, <<"Basic ", Credentials/binary>>, Headers).
add_cookies_header([], Headers) ->
Headers;
add_cookies_header(Cookies, Headers) when is_list(Cookies) ->
%% Format cookies as "name1=value1; name2=value2"
CookieStr = format_cookies(Cookies),
case CookieStr of
<<>> -> Headers;
_ -> hackney_headers:store(<<"Cookie">>, CookieStr, Headers)
end;
add_cookies_header(Cookie, Headers) when is_binary(Cookie) ->
hackney_headers:store(<<"Cookie">>, Cookie, Headers).
format_cookies([]) ->
<<>>;
format_cookies([{Name, Value} | Rest]) ->
First = iolist_to_binary([Name, <<"=">>, Value]),
format_cookies(Rest, First);
format_cookies([Cookie | Rest]) when is_binary(Cookie) ->
format_cookies(Rest, Cookie).
format_cookies([], Acc) ->
Acc;
format_cookies([{Name, Value} | Rest], Acc) ->
format_cookies(Rest, <<Acc/binary, "; ", (iolist_to_binary([Name, <<"=">>, Value]))/binary>>);
format_cookies([Cookie | Rest], Acc) when is_binary(Cookie) ->
format_cookies(Rest, <<Acc/binary, "; ", Cookie/binary>>).
default_ua() ->
<<"hackney/", (list_to_binary(get_version()))/binary>>.
get_version() ->
case application:get_key(hackney, vsn) of
{ok, Vsn} -> Vsn;
undefined -> "1.0.0"
end.
use_pool(Options) ->
UseDefaultPool = case application:get_env(hackney, use_default_pool) of
{ok, Val} -> Val;
_ -> true
end,
case proplists:get_value(pool, Options) of
false -> false;
undefined when UseDefaultPool =:= false -> false;
undefined -> default;
PoolName -> PoolName
end.
maybe_proxy(Transport, Scheme, Host, Port, Options) ->
case get_proxy_config(Scheme, Host, Options) of
false ->
%% No proxy configured, direct connection
connect(Transport, Host, Port, Options);
{connect, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport} ->
%% HTTP CONNECT tunnel (for HTTPS through HTTP/HTTPS proxy)
connect_via_connect_proxy(Transport, Host, Port, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport, Options);
{socks5, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport} ->
%% SOCKS5 proxy
connect_via_socks5_proxy(Transport, Host, Port, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport, Options);
{http, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport} ->
%% Simple HTTP proxy - connect to proxy, use absolute URLs
connect_via_http_proxy(Scheme, Host, Port, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport, Options)
end.
%% @doc Connect through HTTP CONNECT proxy (tunnel).
%% Used for HTTPS requests through HTTP/HTTPS proxy.
%% ProxyTransport: tcp for HTTP proxy, ssl for HTTPS proxy
connect_via_connect_proxy(Transport, Host, Port, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport, Options) ->
%% Build options for hackney_http_connect
ConnectOpts0 = [
{connect_host, Host},
{connect_port, Port},
{connect_transport, Transport},
{proxy_transport, ProxyTransport}
],
ConnectOpts1 = case ProxyAuth of
undefined -> ConnectOpts0;
{User, Pass} -> [{connect_user, User}, {connect_pass, Pass} | ConnectOpts0]
end,
%% Add SSL options if connecting to HTTPS target
ConnectOpts2 = case Transport of
hackney_ssl ->
SslOpts = proplists:get_value(ssl_options, Options, []),
[{ssl_options, SslOpts} | ConnectOpts1];
_ ->
ConnectOpts1
end,
%% Add proxy SSL options if connecting to HTTPS proxy
ConnectOpts3 = case ProxyTransport of
ssl ->
ProxySslOpts = proplists:get_value(proxy_ssl_options, Options, []),
[{proxy_ssl_options, ProxySslOpts} | ConnectOpts2];
_ ->
ConnectOpts2
end,
%% Add recv_timeout for proxy handshake (issue #569)
RecvTimeout = proplists:get_value(recv_timeout, Options, infinity),
ConnectOpts4 = [{recv_timeout, RecvTimeout} | ConnectOpts3],
%% Add other connection options
ConnectOpts = ConnectOpts4 ++ proplists:get_value(connect_options, Options, []),
Timeout = proplists:get_value(connect_timeout, Options, 8000),
case hackney_http_connect:connect(ProxyHost, ProxyPort, ConnectOpts, Timeout) of
{ok, ProxySocket} ->
%% Start hackney_conn with the pre-established socket
%% HTTP CONNECT tunnels should not be reused/pooled (tunnel is destination-specific)
start_conn_with_socket(Host, Port, Transport, ProxySocket, [{no_reuse, true} | Options]);
{error, Reason} ->
{error, Reason}
end.
%% @doc Connect through SOCKS5 proxy.
%% ProxyTransport: tcp for plain SOCKS5, ssl for SOCKS5 over TLS
connect_via_socks5_proxy(Transport, Host, Port, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport, Options) ->
%% Build options for hackney_socks5
Socks5Opts0 = [
{socks5_host, ProxyHost},
{socks5_port, ProxyPort},
{socks5_transport, Transport},
{proxy_transport, ProxyTransport}
],
Socks5Opts1 = case ProxyAuth of
undefined -> Socks5Opts0;
{User, Pass} -> [{socks5_user, User}, {socks5_pass, Pass} | Socks5Opts0]
end,
%% Add SSL options if connecting to HTTPS target
Socks5Opts2 = case Transport of
hackney_ssl ->
SslOpts = proplists:get_value(ssl_options, Options, []),
[{ssl_options, SslOpts} | Socks5Opts1];
_ ->
Socks5Opts1
end,
%% Add proxy SSL options if connecting to SOCKS5 over TLS
Socks5Opts3 = case ProxyTransport of
ssl ->
ProxySslOpts = proplists:get_value(proxy_ssl_options, Options, []),
[{proxy_ssl_options, ProxySslOpts} | Socks5Opts2];
_ ->
Socks5Opts2
end,
%% Add socks5_resolve option if specified
Socks5Opts4 = case proplists:get_value(socks5_resolve, Options) of
undefined -> Socks5Opts3;
Resolve -> [{socks5_resolve, Resolve} | Socks5Opts3]
end,
%% Add other connection options
Socks5Opts = Socks5Opts4 ++ proplists:get_value(connect_options, Options, []),
Timeout = proplists:get_value(connect_timeout, Options, 8000),
case hackney_socks5:connect(Host, Port, Socks5Opts, Timeout) of
{ok, ProxySocket} ->
%% Start hackney_conn with the pre-established socket
%% SOCKS5 tunnels should not be reused/pooled (issue #283)
start_conn_with_socket(Host, Port, Transport, ProxySocket, [{no_reuse, true} | Options]);
{error, Reason} ->
{error, Reason}
end.
%% @doc Connect through simple HTTP proxy (no tunneling).
%% Used for HTTP requests through HTTP/HTTPS proxy. Requests use absolute URLs.
%% ProxyTransport: tcp for HTTP proxy, ssl for HTTPS proxy
connect_via_http_proxy(TargetScheme, TargetHost, TargetPort, ProxyHost, ProxyPort, ProxyAuth, ProxyTransport, Options) ->
%% Determine transport module and options based on proxy transport
{ProxyTransportMod, ConnectOpts} = case ProxyTransport of
ssl ->
%% Use proxy_ssl_options for the proxy connection
ProxySslOpts = proplists:get_value(proxy_ssl_options, Options, []),
%% Replace ssl_options with proxy_ssl_options for the connection
Opts1 = proplists:delete(ssl_options, Options),
{hackney_ssl, [{ssl_options, ProxySslOpts} | Opts1]};
_ ->
{hackney_tcp, Options}
end,
%% Connect directly to the proxy server
case connect(ProxyTransportMod, ProxyHost, ProxyPort, ConnectOpts) of
{ok, ConnPid} ->
%% Return connection with proxy info for absolute URL mode
{ok, ConnPid, {http_proxy, TargetScheme, TargetHost, TargetPort, ProxyAuth}};
{error, Reason} ->
{error, Reason}
end.
%% @doc Build absolute URL for HTTP proxy requests.
build_absolute_url(Scheme, Host, Port, Path) ->
SchemeBin = atom_to_binary(Scheme),
HostBin = hackney_bstr:to_binary(Host),
%% Include port only if non-default
HostPort = case {Scheme, Port} of
{http, 80} -> HostBin;
{https, 443} -> HostBin;
_ -> <<HostBin/binary, ":", (integer_to_binary(Port))/binary>>
end,
<<SchemeBin/binary, "://", HostPort/binary, Path/binary>>.
%% @doc Add Proxy-Authorization header if auth is configured.
add_proxy_auth_header(Headers, undefined) ->
Headers;
add_proxy_auth_header(Headers, {User, Pass}) ->
Credentials = base64:encode(<<User/binary, ":", Pass/binary>>),
AuthHeader = {<<"Proxy-Authorization">>, <<"Basic ", Credentials/binary>>},
[AuthHeader | Headers].
%% @doc Extract proxy configuration from options.
%% Returns: false | {Type, Host, Port, Auth}
%% Type: http | connect | socks5
%% Auth: undefined | {User, Pass}
%% TargetHost is used to check NO_PROXY for environment variable proxies.
-spec get_proxy_config(atom(), string() | binary(), list()) ->
false | {http | connect | socks5, string(), inet:port_number(),
undefined | {binary(), binary()}, tcp | ssl}.
get_proxy_config(Scheme, TargetHost, Options) ->
case proplists:get_value(proxy, Options) of
undefined ->
%% No explicit proxy option, check environment variables
get_proxy_from_env(Scheme, TargetHost, Options);
false ->
false;
ProxyUrl when is_binary(ProxyUrl); is_list(ProxyUrl) ->
parse_proxy_option(ProxyUrl, Scheme, Options);
{ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) ->
%% Simple tuple: use HTTP proxy for http, CONNECT for https
ProxyAuth = proplists:get_value(proxy_auth, Options),
ProxyTransport = proplists:get_value(proxy_transport, Options, tcp),
{proxy_type_for_scheme(Scheme), normalize_proxy_host(ProxyHost), ProxyPort, ProxyAuth, ProxyTransport};
{connect, ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) ->
%% Explicit CONNECT tunnel
ProxyAuth = proplists:get_value(proxy_auth, Options),
ProxyTransport = proplists:get_value(proxy_transport, Options, tcp),
{connect, normalize_proxy_host(ProxyHost), ProxyPort, ProxyAuth, ProxyTransport};
{socks5, ProxyHost, ProxyPort} when (is_list(ProxyHost) orelse is_atom(ProxyHost) orelse is_binary(ProxyHost)), is_integer(ProxyPort) ->
%% SOCKS5 proxy
User = proplists:get_value(socks5_user, Options),
Pass = proplists:get_value(socks5_pass, Options, <<>>),
Auth = case User of
undefined -> undefined;
_ -> {User, Pass}
end,
ProxyTransport = proplists:get_value(proxy_transport, Options, tcp),
{socks5, normalize_proxy_host(ProxyHost), ProxyPort, Auth, ProxyTransport};
_ ->
false
end.
%% @private Accept a proxy host given as a string, a binary or an atom
%% (e.g. `localhost'), as hackney 1.x did, and normalise it to a string.
%% A new is_list/1 guard regressed atom hosts to a silent fall-through (#858).
normalize_proxy_host(H) when is_list(H) -> H;
normalize_proxy_host(H) when is_atom(H) -> atom_to_list(H);
normalize_proxy_host(H) when is_binary(H) -> binary_to_list(H).
%% Parse proxy URL and determine type based on target scheme
parse_proxy_option(ProxyUrl, Scheme, Options) ->
case parse_proxy_url(ProxyUrl) of
{ok, #{scheme := ProxyScheme, host := ProxyHost, port := ProxyPort,
user := User, password := Pass}} ->
Auth = case User of
undefined -> proplists:get_value(proxy_auth, Options);
_ -> {User, Pass}
end,
Type = case ProxyScheme of
socks5 -> socks5;
_ -> proxy_type_for_scheme(Scheme)
end,
%% Determine proxy transport based on proxy URL scheme
ProxyTransport = case ProxyScheme of
https -> ssl;
_ -> tcp
end,
{Type, ProxyHost, ProxyPort, Auth, ProxyTransport};
{error, _} ->
false
end.
%% @doc Get proxy configuration from environment variables.
%% Checks HTTP_PROXY, HTTPS_PROXY, ALL_PROXY based on scheme.
%% Returns false if NO_PROXY matches the target host.
get_proxy_from_env(Scheme, TargetHost, Options) ->
case get_proxy_env(Scheme) of
false ->
false;
{ok, ProxyUrl} ->
%% Check if target host is in NO_PROXY list
case check_no_proxy(TargetHost, get_no_proxy()) of
true ->
%% Host is in NO_PROXY, don't use proxy
false;
false ->
parse_proxy_option(ProxyUrl, Scheme, Options)
end
end.
%% @doc Get proxy URL from environment variables.
get_proxy_env(Scheme) ->
case Scheme of
https -> get_proxy_env_https();
_ -> get_proxy_env_http()
end.
get_proxy_env_https() ->
do_get_proxy_env(?HTTPS_PROXY_ENV_VARS).
get_proxy_env_http() ->
do_get_proxy_env(?HTTP_PROXY_ENV_VARS).
do_get_proxy_env([Var | Rest]) when is_list(Var) ->
case os:getenv(Var) of
false -> do_get_proxy_env(Rest);
Url ->
%% Trim whitespace from the URL
TrimmedUrl = string:trim(Url),
case TrimmedUrl of
"" -> do_get_proxy_env(Rest);
_ -> {ok, TrimmedUrl}
end
end;
do_get_proxy_env([]) ->
false.
%% @doc Get NO_PROXY list from environment variables.
%% Returns list of hosts/domains to bypass proxy.
get_no_proxy() ->
case do_get_proxy_env(?HTTP_NO_PROXY_ENV_VARS) of
false -> [];
{ok, NoProxyStr} ->
%% Split by comma and trim each entry
Entries = string:split(NoProxyStr, ",", all),
[string:trim(E) || E <- Entries, string:trim(E) =/= ""]
end.
%% @doc Check if a host matches any NO_PROXY entry.
%% Supports exact match, suffix match (with leading dot), and wildcard (*).
%% Returns true if proxy should be bypassed for this host.
%% Note: binary clause is for API flexibility (used in tests).
-dialyzer({nowarn_function, check_no_proxy/2}).
check_no_proxy(_Host, []) ->
false;
check_no_proxy(Host, NoProxyList) when is_binary(Host) ->
check_no_proxy(binary_to_list(Host), NoProxyList);
check_no_proxy(Host, NoProxyList) ->
LowerHost = string:lowercase(Host),
lists:any(fun(Entry) -> matches_no_proxy(LowerHost, Entry) end, NoProxyList).
%% Check if host matches a single NO_PROXY entry
matches_no_proxy(_Host, "*") ->
%% Wildcard matches everything
true;
matches_no_proxy(Host, Entry) ->
LowerEntry = string:lowercase(Entry),
case LowerEntry of
[$. | Domain] ->
%% Leading dot: match any subdomain
string:find(Host, Domain, trailing) =:= Domain;
_ ->
%% Exact match or suffix match
Host =:= LowerEntry orelse
lists:suffix("." ++ LowerEntry, Host)
end.
%% Determine proxy type based on target scheme
proxy_type_for_scheme(https) -> connect;
proxy_type_for_scheme(_) -> http.
%% HTTP method helpers
-define(METHOD_TPL(Method),
Method(URL) ->
hackney:request(Method, URL)).
-include("hackney_methods.hrl").
-define(METHOD_TPL(Method),
Method(URL, Headers) ->
hackney:request(Method, URL, Headers)).
-include("hackney_methods.hrl").
-define(METHOD_TPL(Method),
Method(URL, Headers, Body) ->
hackney:request(Method, URL, Headers, Body)).
-include("hackney_methods.hrl").
-define(METHOD_TPL(Method),
Method(URL, Headers, Body, Options) ->
hackney:request(Method, URL, Headers, Body, Options)).
-include("hackney_methods.hrl").
%% @doc Parse a proxy URL and extract host, port, and optional credentials.
%% Supports URLs like:
%% - "http://proxy.example.com:8080"
%% - "http://user:pass@proxy.example.com:8080"
%% - "https://admin:secret@secure-proxy.example.com:443"
%% - "socks5://socks.example.com:1080"
%% - "socks5://user:pass@socks.example.com:1080"
%%
%% Returns a map with keys: scheme, host, port, user, password
%% Fixes issue #741: Extract proxy basic auth from URL
-spec parse_proxy_url(binary() | string()) ->
{ok, #{scheme := atom(),
host := string(),
port := inet:port_number(),
user := binary() | undefined,
password := binary() | undefined}} |
{error, invalid_proxy_url}.
parse_proxy_url(Url) when is_list(Url) ->
parse_proxy_url(list_to_binary(Url));
parse_proxy_url(Url) when is_binary(Url) ->
try
#hackney_url{
scheme = Scheme,
host = Host,
port = Port,
user = User,
password = Password
} = hackney_url:parse_url(Url),
{ok, #{
scheme => Scheme,
host => Host,
port => Port,
user => case User of <<>> -> undefined; _ -> User end,
password => case Password of <<>> -> undefined; _ -> Password end
}}
catch
_:_ -> {error, invalid_proxy_url}
end.