diff --git a/changelog.md b/changelog.md index 3acbc13..877d9fa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,26 @@ # ehttpc changes +## 0.7.7 + +- Added TCP keepalive for the `keepalive` pool option, which used to be ignored. On Linux + and macOS, probes start after `keepalive` milliseconds of idle time, rounded down to whole + seconds and kept between 1 and 32767 seconds. They are sent every 5 seconds, or at the + idle time if that is shorter, and the connection is closed after 3 unanswered probes. + Other systems only enable `SO_KEEPALIVE` and use the OS timers. `{keepalive, false}` in + `transport_opts` turns it off, and any other keepalive option set there replaces the + matching one. +- Fixed `keepidle`, `keepintvl` and `keepcnt` (OTP 28.3 and later) in `transport_opts` + being dropped for `tcp` and passed to the TLS options for `tls`. They now go to + `gen_tcp`. A connection that sets them fails before OTP 28.3, and one that sets + `keepidle` fails on macOS. +- Fixed `transport_opts` being ignored when it came before `transport` or when `transport` + was not set, and `transport` being ignored without `transport_opts`. A `proxy` that sets + `transport` now uses it and its `tls_opts` for the connection to the proxy. +- With a `proxy`, the `gen_tcp` options in `transport_opts`, including the keepalive ones, + now apply to the connection to the proxy, the only TCP connection, and only the TLS + options are used for the target. All of them used to be passed to the target's TLS + options. + ## 0.7.6 - Zombie-connection detection no longer waits for the full request timeout. A connection diff --git a/src/ehttpc.app.src b/src/ehttpc.app.src index 6bbce1a..baf43e0 100644 --- a/src/ehttpc.app.src +++ b/src/ehttpc.app.src @@ -1,6 +1,6 @@ {application, ehttpc, [ {description, "HTTP Client for Erlang/OTP"}, - {vsn, "0.7.6"}, + {vsn, "0.7.7"}, {registered, []}, {applications, [ kernel, diff --git a/src/ehttpc.erl b/src/ehttpc.erl index 6ea35c9..fa58e43 100644 --- a/src/ehttpc.erl +++ b/src/ehttpc.erl @@ -491,7 +491,7 @@ gun_opts(Opts) -> %% retry at lower level will likely cause %% gen_server callers to time out anyway GunNoRetry = 0, - gun_opts(Opts, #{ + GunOpts = gun_opts(Opts, #{ retry => GunNoRetry, connect_timeout => 5000, %% The keepalive mechanism of gun will send "\r\n" for keepalive, @@ -500,7 +500,94 @@ gun_opts(Opts) -> protocols => [http], %% Link with client process directly. supervise => false - }). + }), + TransportGunOpts = transport_gun_opts( + proplists:get_value(transport, Opts), + proplists:get_value(transport_opts, Opts), + proplists:get_value(port, Opts) + ), + with_tcp_keepalive( + proplists:get_value(keepalive, Opts, infinity), maps:merge(GunOpts, TransportGunOpts) + ). + +%% `transport' and `transport_opts' can be anywhere in the pool options, and +%% the first of each is used. Without `transport', `transport_opts' applies +%% to the transport gun picks from the port. +transport_gun_opts(undefined, undefined, _Port) -> + #{}; +transport_gun_opts(undefined, TransportOpts, Port) -> + transport_gun_opts(gun:default_transport(Port), TransportOpts); +transport_gun_opts(Transport, undefined, _Port) -> + #{transport => Transport}; +transport_gun_opts(Transport, TransportOpts, _Port) -> + transport_gun_opts(Transport, TransportOpts). + +transport_gun_opts(tcp, TransportOpts) -> + {TCPOpts, _} = split_transport_opts(TransportOpts), + #{transport => tcp, tcp_opts => TCPOpts}; +transport_gun_opts(Transport, TransportOpts) when Transport == tls; Transport == ssl -> + {TCPOpts, TLSOpts} = split_transport_opts(TransportOpts), + #{transport => Transport, tcp_opts => TCPOpts, tls_opts => TLSOpts}. + +%% The `keepalive' option is the idle time in milliseconds before TCP +%% keepalive probes start. `{keepalive, false}' in `transport_opts' turns +%% it off. Any other keepalive option set in `transport_opts' replaces the +%% one the `keepalive' option would set. +with_tcp_keepalive(Timeout, GunOpts) when is_integer(Timeout), Timeout > 0 -> + %% gun uses these send timeouts only when `tcp_opts' is not set, and the + %% keepalive options are added to `tcp_opts'. + TCPOpts = maps:get(tcp_opts, GunOpts, [{send_timeout, 15000}, {send_timeout_close, true}]), + case lists:member({keepalive, false}, TCPOpts) of + true -> + GunOpts; + false -> + KeepaliveOpts = [ + Opt + || {Name, Opt} <- tcp_keepalive_opts(os:type(), Timeout), + not is_tcp_opt_set(Name, Opt, TCPOpts) + ], + GunOpts#{tcp_opts => KeepaliveOpts ++ TCPOpts} + end; +with_tcp_keepalive(_Timeout, GunOpts) -> + GunOpts. + +%% Each option is paired with the name of the native option that sets the +%% same value. Probes are sent every 5 seconds, or at the idle time if that +%% is shorter, and the connection is dropped after 3 unanswered probes. Raw +%% options are used because the native ones only exist since OTP 28.3, and +%% native `keepidle' is Linux only. Linux rejects an idle time above 32767 +%% seconds, and a rejected raw option is silently ignored. +tcp_keepalive_opts(OS, Timeout) -> + Idle = min(32767, max(1, Timeout div 1000)), + Interval = min(Idle, 5), + Count = 3, + case OS of + {unix, linux} -> + [ + {keepalive, {keepalive, true}}, + {keepidle, {raw, 6, 4, <>}}, + {keepintvl, {raw, 6, 5, <>}}, + {keepcnt, {raw, 6, 6, <>}} + ]; + {unix, darwin} -> + [ + {keepalive, {keepalive, true}}, + {keepidle, {raw, 6, 16#10, <>}}, + {keepintvl, {raw, 6, 16#101, <>}}, + {keepcnt, {raw, 6, 16#102, <>}} + ]; + _ -> + [{keepalive, {keepalive, true}}] + end. + +is_tcp_opt_set(Name, {raw, Level, Num, _}, TCPOpts) -> + lists:keymember(Name, 1, TCPOpts) orelse + lists:any(fun(O) -> is_raw_opt(Level, Num, O) end, TCPOpts); +is_tcp_opt_set(Name, _Opt, TCPOpts) -> + lists:keymember(Name, 1, TCPOpts). + +is_raw_opt(Level, Num, {raw, Level, Num, _}) -> true; +is_raw_opt(_Level, _Num, _Opt) -> false. gun_opts([], Acc) -> Acc; @@ -514,20 +601,6 @@ gun_opts([{connect_timeout, ConnectTimeout} | Opts], Acc) -> gun_opts(Opts, Acc#{connect_timeout => ConnectTimeout}); gun_opts([{protocols, Protocols} | Opts], Acc) -> gun_opts(Opts, Acc#{protocols => Protocols}); -gun_opts([{transport, Transport} | Opts0], Acc0) -> - Acc1 = Acc0#{transport => Transport}, - case lists:keytake(transport_opts, 1, Opts0) of - {value, {_, TransportOpts}, Opts} when Transport == tcp -> - {TCPOpts, _} = split_transport_opts(TransportOpts), - Acc = Acc1#{tcp_opts => TCPOpts}; - {value, {_, TransportOpts}, Opts} when Transport == tls; Transport == ssl -> - {TCPOpts, TLSOpts} = split_transport_opts(TransportOpts), - Acc = Acc1#{tcp_opts => TCPOpts, tls_opts => TLSOpts}; - false -> - Acc = Acc0, - Opts = Opts0 - end, - gun_opts(Opts, Acc); gun_opts([_ | Opts], Acc) -> %% ignore by default gun_opts(Opts, Acc). @@ -561,6 +634,10 @@ is_gen_tcp_option({header, _}) -> ignore; is_gen_tcp_option({high_msgq_watermark, _}) -> true; is_gen_tcp_option({high_watermark, _}) -> true; is_gen_tcp_option({keepalive, _}) -> true; +%% OTP 28.3 and later, keepidle is Linux only +is_gen_tcp_option({keepcnt, _}) -> true; +is_gen_tcp_option({keepidle, _}) -> true; +is_gen_tcp_option({keepintvl, _}) -> true; is_gen_tcp_option({linger, _}) -> true; is_gen_tcp_option({low_msgq_watermark, _}) -> true; is_gen_tcp_option({low_watermark, _}) -> true; @@ -1176,7 +1253,7 @@ parse_proxy_opts(Opts) -> #{opts => Opts, origin => undefined}; #{host := _, port := _} = ProxyOpts0 -> %% We open connection to proxy, then issue `gun:connect' to target host. - {Origin, NewOpts} = + {Origin0, NewOpts0} = lists:foldl( fun(Key, {OriginAcc, GunAcc}) -> swap(Key, OriginAcc, GunAcc) @@ -1184,9 +1261,27 @@ parse_proxy_opts(Opts) -> {ProxyOpts0, proplists:delete(proxy, Opts)}, [host, port, transport, {tls_opts, transport_opts}] ), + {Origin, NewOpts} = move_tcp_opts_to_proxy(Origin0, NewOpts0), #{opts => NewOpts, origin => Origin} end. +%% The connection to the proxy is the only TCP connection, so the target's +%% `gen_tcp' options apply to it, before the proxy's own `tls_opts', and only +%% the TLS options are left for the target. +move_tcp_opts_to_proxy(#{tls_opts := TransportOpts} = Origin, Opts) -> + case split_transport_opts(TransportOpts) of + {[], TLSOpts} -> + {Origin#{tls_opts => TLSOpts}, Opts}; + {TCPOpts, TLSOpts} -> + ProxyTransportOpts = TCPOpts ++ proplists:get_value(transport_opts, Opts, []), + { + Origin#{tls_opts => TLSOpts}, + [{transport_opts, ProxyTransportOpts} | proplists:delete(transport_opts, Opts)] + } + end; +move_tcp_opts_to_proxy(Origin, Opts) -> + {Origin, Opts}. + swap(Key, Map, Proplist) when is_atom(Key) -> swap({Key, Key}, Map, Proplist); swap({KeyM, KeyP}, Map0, Proplist0) when is_map_key(KeyM, Map0) -> @@ -1288,4 +1383,150 @@ prioritise_oldest_test() -> ?assertMatch({{value, 1}, _}, OutOldest(Q)), ?assertMatch({{value, 1}, _}, queue:out(Q)). +tcp_keepalive_gun_opts_test_() -> + KeepaliveOpts = expected_tcp_keepalive_opts(os:type(), 30, 5), + %% The raw option that sets the idle time, on systems that have one + IdleRaw = [O || {raw, _, N, _} = O <- KeepaliveOpts, N =:= 4 orelse N =:= 16#10], + CallerIdleRaw = [{raw, L, N, <<60:32/native>>} || {raw, L, N, _} <- IdleRaw], + TCP = [{transport, tcp}, {transport_opts, [{nodelay, true}]}], + TLS = [{transport, tls}, {transport_opts, [{nodelay, true}, {verify, verify_none}]}], + TCPOpts = fun(Opts) -> maps:get(tcp_opts, gun_opts(Opts)) end, + WithTCPOpts = fun(Opts) -> + TCPOpts([{keepalive, 30_000}, {transport, tcp}, {transport_opts, Opts}]) + end, + [ + ?_assertEqual([{nodelay, true}], TCPOpts(TCP)), + ?_assertEqual([{nodelay, true}], TCPOpts([{keepalive, infinity} | TCP])), + ?_assertEqual(KeepaliveOpts ++ [{nodelay, true}], TCPOpts([{keepalive, 30_000} | TCP])), + ?_assertEqual( + #{tcp_opts => KeepaliveOpts ++ [{nodelay, true}], tls_opts => [{verify, verify_none}]}, + maps:with([tcp_opts, tls_opts], gun_opts([{keepalive, 30_000} | TLS])) + ), + ?_assertEqual( + KeepaliveOpts ++ [{send_timeout, 15000}, {send_timeout_close, true}], + TCPOpts([{keepalive, 30_000}]) + ), + ?_assertEqual( + expected_tcp_keepalive_opts(os:type(), 1, 1) ++ [{nodelay, true}], + TCPOpts([{keepalive, 500} | TCP]) + ), + ?_assertEqual([{keepalive, false}], WithTCPOpts([{keepalive, false}])), + ?_assertEqual( + tl(KeepaliveOpts) ++ [{keepalive, true}], + WithTCPOpts([{keepalive, true}]) + ), + ?_assertEqual( + (KeepaliveOpts -- IdleRaw) ++ [{keepidle, 60}], + WithTCPOpts([{keepidle, 60}]) + ), + ?_assertEqual( + (KeepaliveOpts -- IdleRaw) ++ CallerIdleRaw, + WithTCPOpts(CallerIdleRaw) + ) + ]. + +tcp_keepalive_opts_test_() -> + Opts = fun(OS, Timeout) -> [O || {_Name, O} <- tcp_keepalive_opts(OS, Timeout)] end, + [ + ?_assertEqual(expected_tcp_keepalive_opts(OS, Idle, Interval), Opts(OS, Timeout)) + || OS <- [{unix, linux}, {unix, darwin}, {unix, freebsd}, {win32, nt}], + {Timeout, Idle, Interval} <- [ + {500, 1, 1}, {3_000, 3, 3}, {30_000, 30, 5}, {40_000_000, 32767, 5} + ] + ]. + +transport_gun_opts_test_() -> + Transport = fun(Opts) -> maps:with([transport, tcp_opts, tls_opts], gun_opts(Opts)) end, + #{opts := ProxyOpts} = parse_proxy_opts([ + {host, "example.com"}, + {port, 443}, + {transport, tcp}, + {proxy, #{ + host => "proxy", port => 3128, transport => tls, tls_opts => [{verify, verify_none}] + }} + ]), + [ + ?_assertEqual( + #{transport => tcp, tcp_opts => [{keepalive, false}]}, + Transport([ + {transport_opts, [{keepalive, false}]}, {transport, tcp}, {keepalive, 30_000} + ]) + ), + ?_assertEqual( + #{transport => tls, tcp_opts => [{nodelay, true}], tls_opts => [{verify, verify_none}]}, + Transport([{transport_opts, [{nodelay, true}, {verify, verify_none}]}, {transport, tls}]) + ), + ?_assertEqual(#{transport => tls}, Transport([{port, 8443}, {transport, tls}])), + ?_assertEqual( + #{transport => tcp, tcp_opts => [{keepalive, false}]}, + Transport([{port, 8080}, {transport_opts, [{keepalive, false}]}, {keepalive, 30_000}]) + ), + ?_assertEqual( + #{transport => tls, tcp_opts => [], tls_opts => [{verify, verify_none}]}, + Transport([{port, 443}, {transport_opts, [{verify, verify_none}]}]) + ), + ?_assertEqual( + #{transport => tls, tcp_opts => [], tls_opts => [{verify, verify_none}]}, + Transport(ProxyOpts) + ) + ]. + +proxy_tcp_opts_test_() -> + Parse = fun(ProxyOpts) -> + #{opts := Opts, origin := Origin} = parse_proxy_opts([ + {host, "example.com"}, + {port, 443}, + {transport, tls}, + {transport_opts, [{keepalive, false}, {nodelay, true}, {verify, verify_none}]}, + {keepalive, 30_000}, + {proxy, ProxyOpts} + ]), + { + maps:with([transport, tcp_opts, tls_opts], gun_opts(Opts)), + maps:with([transport, tls_opts], Origin) + } + end, + [ + ?_assertEqual( + { + #{transport => tcp, tcp_opts => [{keepalive, false}, {nodelay, true}]}, + #{transport => tls, tls_opts => [{verify, verify_none}]} + }, + Parse(#{host => "proxy", port => 3128}) + ), + ?_assertEqual( + { + #{ + transport => tls, + tcp_opts => [{keepalive, false}, {nodelay, true}], + tls_opts => [{server_name_indication, "proxy"}] + }, + #{transport => tls, tls_opts => [{verify, verify_none}]} + }, + Parse(#{ + host => "proxy", + port => 3128, + transport => tls, + tls_opts => [{server_name_indication, "proxy"}] + }) + ) + ]. + +expected_tcp_keepalive_opts({unix, linux}, Idle, Interval) -> + [ + {keepalive, true}, + {raw, 6, 4, <>}, + {raw, 6, 5, <>}, + {raw, 6, 6, <<3:32/native>>} + ]; +expected_tcp_keepalive_opts({unix, darwin}, Idle, Interval) -> + [ + {keepalive, true}, + {raw, 6, 16#10, <>}, + {raw, 6, 16#101, <>}, + {raw, 6, 16#102, <<3:32/native>>} + ]; +expected_tcp_keepalive_opts(_OS, _Idle, _Interval) -> + [{keepalive, true}]. + -endif. diff --git a/test/ehttpc_tests.erl b/test/ehttpc_tests.erl index 4cf7755..85901f1 100644 --- a/test/ehttpc_tests.erl +++ b/test/ehttpc_tests.erl @@ -890,6 +890,45 @@ hash_pool_test() -> end, ?WITH(ServerOpts1, PoolOpts1, Send()). +tcp_keepalive_test_() -> + Port = ?PORT, + ServerOpts = #{port => Port, name => ?FUNCTION_NAME, delay => 0}, + PoolOpts = [{keepalive, 30_000} | pool_opts(Port, false)], + TCPPoolOpts = [{transport, tcp}, {transport_opts, [{nodelay, true}]} | PoolOpts], + Check = fun() -> + ?assertMatch({ok, 200, _, _}, ehttpc:request(?POOL, get, req(), 1000, 0)), + {_, #{client := Client}} = ehttpc:get_state(?POOL), + #{socket := Socket} = gun:info(Client), + ?assertEqual({ok, [{keepalive, true}]}, inet:getopts(Socket, [keepalive])), + assert_tcp_keepalive_timers(Socket, os:type()) + end, + OptOutPoolOpts = [{transport_opts, [{keepalive, false}]}, {transport, tcp} | PoolOpts], + CheckOptOut = fun() -> + ?assertMatch({ok, 200, _, _}, ehttpc:request(?POOL, get, req(), 1000, 0)), + {_, #{client := Client}} = ehttpc:get_state(?POOL), + #{socket := Socket} = gun:info(Client), + ?assertEqual({ok, [{keepalive, false}]}, inet:getopts(Socket, [keepalive])) + end, + [ + {"no transport_opts", fun() -> ?WITH(ServerOpts, PoolOpts, Check()) end}, + {"tcp transport_opts", fun() -> ?WITH(ServerOpts, TCPPoolOpts, Check()) end}, + {"opt-out before transport", fun() -> ?WITH(ServerOpts, OptOutPoolOpts, CheckOptOut()) end} + ]. + +%% Idle time, probe interval and probe count, in this order. +assert_tcp_keepalive_timers(Socket, {unix, linux}) -> + assert_raw_tcp_opts(Socket, [{4, 30}, {5, 5}, {6, 3}]); +assert_tcp_keepalive_timers(Socket, {unix, darwin}) -> + assert_raw_tcp_opts(Socket, [{16#10, 30}, {16#101, 5}, {16#102, 3}]); +assert_tcp_keepalive_timers(_Socket, _OS) -> + ok. + +assert_raw_tcp_opts(Socket, Expected) -> + ?assertEqual( + {ok, [{raw, 6, Opt, <>} || {Opt, Value} <- Expected]}, + inet:getopts(Socket, [{raw, 6, Opt, 4} || {Opt, _} <- Expected]) + ). + with_server(Port, Name, Delay, F) -> ehttpc_test_lib:with_server(Port, Name, Delay, F).