Packages
hackney
2.0.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
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
src/hackney_altsvc.erl
%%% -*- erlang -*-
%%%
%%% This file is part of hackney released under the Apache 2 license.
%%% See the NOTICE for more information.
%%%
%%% Copyright (c) 2024-2026 Benoit Chesneau
%%%
%%% @doc Alt-Svc header parsing and caching for HTTP/3 discovery.
%%%
%%% This module parses Alt-Svc response headers to discover HTTP/3 endpoints
%%% and caches them for future requests.
%%%
%%% == Alt-Svc Header Format ==
%%% ```
%%% Alt-Svc: h3=":443"; ma=86400, h3-29=":443"
%%% Alt-Svc: clear
%%% '''
%%%
%%% == Usage ==
%%% After receiving an HTTP/1.1 or HTTP/2 response, check for Alt-Svc:
%%% ```
%%% case hackney_altsvc:parse_and_cache(Host, Headers) of
%%% {ok, h3, Port} -> %% HTTP/3 available on Port
%%% none -> %% No HTTP/3 advertised
%%% end
%%% '''
%%%
%%% Before connecting, check the cache:
%%% ```
%%% case hackney_altsvc:lookup(Host, Port) of
%%% {ok, h3, H3Port} -> %% Try HTTP/3 on H3Port
%%% none -> %% No cached Alt-Svc
%%% end
%%% '''
-module(hackney_altsvc).
-export([
init/0,
parse/1,
parse_and_cache/3,
lookup/2,
cache/4,
clear/2,
clear_all/0,
is_h3_blocked/2,
mark_h3_blocked/2
]).
-define(ALTSVC_TABLE, hackney_altsvc_cache).
-define(DEFAULT_MAX_AGE, 86400). %% 24 hours
-define(BLOCKED_TTL, 300). %% 5 minutes for negative cache
%%====================================================================
%% API
%%====================================================================
%% @doc Initialize the Alt-Svc cache. Called at application start.
-spec init() -> ok.
init() ->
case ets:info(?ALTSVC_TABLE) of
undefined ->
_ = ets:new(?ALTSVC_TABLE, [set, public, named_table,
{read_concurrency, true}]),
ok;
_ ->
ok
end.
%% @doc Parse an Alt-Svc header value.
%% Returns list of {Protocol, Host, Port, MaxAge} tuples.
%% Protocol is h3 atom for HTTP/3 variants, or binary for other protocols.
%% Host is 'same' if not specified (use origin host).
-spec parse(binary() | string()) -> [{h3 | binary(), same | binary(), inet:port_number(), non_neg_integer()}].
parse(<<"clear">>) -> [];
parse("clear") -> [];
parse(Header) when is_list(Header) ->
parse(list_to_binary(Header));
parse(Header) when is_binary(Header) ->
parse_entries(Header, []).
%% @doc Parse Alt-Svc header from response headers and cache if h3 found.
%% Returns {ok, h3, Port} if HTTP/3 is available, or none.
-spec parse_and_cache(Host :: binary() | string(), Port :: inet:port_number(),
Headers :: [{binary(), binary()}]) ->
{ok, h3, inet:port_number()} | none.
parse_and_cache(Host, OrigPort, Headers) ->
case find_altsvc_header(Headers) of
undefined ->
none;
Value ->
Entries = parse(Value),
case find_h3_entry(Entries) of
{ok, H3Port, MaxAge} ->
cache(Host, OrigPort, H3Port, MaxAge),
{ok, h3, H3Port};
none ->
none
end
end.
%% @doc Lookup cached Alt-Svc for a host/port.
%% Returns {ok, h3, H3Port} if HTTP/3 is available and not expired.
-spec lookup(Host :: binary() | string(), Port :: inet:port_number()) ->
{ok, h3, inet:port_number()} | none.
lookup(Host, Port) ->
Key = make_key(Host, Port),
case ets:lookup(?ALTSVC_TABLE, Key) of
[{Key, {h3, H3Port, Expiry}}] ->
Now = erlang:system_time(second),
case Now < Expiry of
true -> {ok, h3, H3Port};
false ->
ets:delete(?ALTSVC_TABLE, Key),
none
end;
_ ->
none
end.
%% @doc Cache an Alt-Svc entry.
-spec cache(Host :: binary() | string(), OrigPort :: inet:port_number(),
H3Port :: inet:port_number(), MaxAge :: non_neg_integer()) -> ok.
cache(Host, OrigPort, H3Port, MaxAge) ->
Key = make_key(Host, OrigPort),
Expiry = erlang:system_time(second) + MaxAge,
ets:insert(?ALTSVC_TABLE, {Key, {h3, H3Port, Expiry}}),
ok.
%% @doc Clear cached Alt-Svc for a host/port.
-spec clear(Host :: binary() | string(), Port :: inet:port_number()) -> ok.
clear(Host, Port) ->
Key = make_key(Host, Port),
ets:delete(?ALTSVC_TABLE, Key),
ok.
%% @doc Clear all cached Alt-Svc entries.
-spec clear_all() -> ok.
clear_all() ->
ets:delete_all_objects(?ALTSVC_TABLE),
ok.
%% @doc Check if HTTP/3 is blocked for a host (negative cache).
-spec is_h3_blocked(Host :: binary() | string(), Port :: inet:port_number()) -> boolean().
is_h3_blocked(Host, Port) ->
Key = {blocked, make_key(Host, Port)},
case ets:lookup(?ALTSVC_TABLE, Key) of
[{Key, Expiry}] ->
Now = erlang:system_time(second),
case Now < Expiry of
true -> true;
false ->
ets:delete(?ALTSVC_TABLE, Key),
false
end;
_ ->
false
end.
%% @doc Mark HTTP/3 as blocked for a host (negative cache for 5 min).
-spec mark_h3_blocked(Host :: binary() | string(), Port :: inet:port_number()) -> ok.
mark_h3_blocked(Host, Port) ->
Key = {blocked, make_key(Host, Port)},
Expiry = erlang:system_time(second) + ?BLOCKED_TTL,
ets:insert(?ALTSVC_TABLE, {Key, Expiry}),
ok.
%%====================================================================
%% Internal functions
%%====================================================================
make_key(Host, Port) when is_list(Host) ->
make_key(list_to_binary(Host), Port);
make_key(Host, Port) when is_binary(Host) ->
{string:lowercase(Host), Port}.
find_altsvc_header([]) ->
undefined;
find_altsvc_header([{Key, Value} | Rest]) ->
case string:lowercase(Key) of
<<"alt-svc">> -> Value;
_ -> find_altsvc_header(Rest)
end.
find_h3_entry([]) ->
none;
find_h3_entry([{Protocol, _Host, Port, MaxAge} | Rest]) ->
case is_h3_protocol(Protocol) of
true -> {ok, Port, MaxAge};
false -> find_h3_entry(Rest)
end.
%% Protocol is already normalized to h3 atom by normalize_protocol/1
is_h3_protocol(h3) -> true;
is_h3_protocol(_) -> false.
%% Parse comma-separated Alt-Svc entries
parse_entries(<<>>, Acc) ->
lists:reverse(Acc);
parse_entries(Data, Acc) ->
{Entry, Rest} = parse_entry(skip_ws(Data)),
case Entry of
undefined -> parse_entries(skip_comma(Rest), Acc);
_ -> parse_entries(skip_comma(Rest), [Entry | Acc])
end.
%% Parse a single Alt-Svc entry: protocol="host:port"; ma=N
parse_entry(<<>>) ->
{undefined, <<>>};
parse_entry(Data) ->
case parse_protocol(Data) of
{undefined, Rest} ->
{undefined, Rest};
{Protocol, Rest1} ->
case skip_ws(Rest1) of
<<"=", Rest2/binary>> ->
{HostPort, Rest3} = parse_quoted_or_token(skip_ws(Rest2)),
{Host, Port} = parse_host_port(HostPort),
{MaxAge, Rest4} = parse_params(Rest3),
{{Protocol, Host, Port, MaxAge}, Rest4};
Rest2 ->
{undefined, Rest2}
end
end.
parse_protocol(Data) ->
parse_token(Data, <<>>).
parse_token(<<C, Rest/binary>>, Acc) when C >= $a, C =< $z;
C >= $A, C =< $Z;
C >= $0, C =< $9;
C =:= $-; C =:= $_ ->
parse_token(Rest, <<Acc/binary, C>>);
parse_token(Rest, <<>>) ->
{undefined, Rest};
parse_token(Rest, Acc) ->
{normalize_protocol(Acc), Rest}.
normalize_protocol(<<"h3">>) -> h3;
normalize_protocol(<<"h3-", _/binary>>) -> h3; % h3-29, h3-34, etc.
normalize_protocol(Other) -> Other.
parse_quoted_or_token(<<$", Rest/binary>>) ->
parse_quoted(Rest, <<>>);
parse_quoted_or_token(Data) ->
parse_token_value(Data, <<>>).
parse_quoted(<<$", Rest/binary>>, Acc) ->
{Acc, Rest};
parse_quoted(<<$\\, C, Rest/binary>>, Acc) ->
parse_quoted(Rest, <<Acc/binary, C>>);
parse_quoted(<<C, Rest/binary>>, Acc) ->
parse_quoted(Rest, <<Acc/binary, C>>);
parse_quoted(<<>>, Acc) ->
{Acc, <<>>}.
parse_token_value(<<C, Rest/binary>>, Acc) when C =/= $;, C =/= $,, C =/= $ ->
parse_token_value(Rest, <<Acc/binary, C>>);
parse_token_value(Rest, Acc) ->
{Acc, Rest}.
parse_host_port(<<$:, PortBin/binary>>) ->
%% No host, just ":port"
Port = binary_to_integer(PortBin),
{same, Port};
parse_host_port(HostPort) ->
case binary:split(HostPort, <<":">>) of
[Host, PortBin] ->
Port = binary_to_integer(PortBin),
{Host, Port};
[PortBin] ->
%% Just port number
Port = binary_to_integer(PortBin),
{same, Port}
end.
parse_params(Data) ->
parse_params(skip_ws(Data), ?DEFAULT_MAX_AGE).
parse_params(<<$;, Rest/binary>>, MaxAge) ->
{Key, Rest2} = parse_token(skip_ws(Rest), <<>>),
case skip_ws(Rest2) of
<<"=", Rest3/binary>> ->
{Value, Rest4} = parse_token_value(skip_ws(Rest3), <<>>),
case Key of
<<"ma">> ->
NewMaxAge = try binary_to_integer(Value)
catch error:badarg -> MaxAge end,
parse_params(Rest4, NewMaxAge);
_ ->
parse_params(Rest4, MaxAge)
end;
Rest3 ->
parse_params(Rest3, MaxAge)
end;
parse_params(Rest, MaxAge) ->
{MaxAge, Rest}.
skip_ws(<<$\s, Rest/binary>>) -> skip_ws(Rest);
skip_ws(<<$\t, Rest/binary>>) -> skip_ws(Rest);
skip_ws(Data) -> Data.
skip_comma(<<$,, Rest/binary>>) -> skip_ws(Rest);
skip_comma(Data) -> Data.