Skip to content

Commit e26c862

Browse files
committed
test: add error-path integration tests for H1 and H2
New error_path_SUITE with 14 test cases covering: - connection refused for both protocols - connection timeout against non-routable address - unknown option rejection - double-close idempotency - is_open state after close - server sending garbage data (H1) - server closing mid-response (H1) - unified API error paths Uses local TCP listeners so no Docker dependency.
1 parent e34b0f2 commit e26c862

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

rebar.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
%% Exclude h2_compliance_SUITE by default (slow, requires special Docker setup)
105105
%% Run it explicitly with: rebar3 ct --suite=h2_compliance_SUITE
106106
{suites, [
107+
error_path_SUITE,
107108
features_SUITE,
108109
http1_SUITE,
109110
http2_SUITE,

test/error_path_SUITE.erl

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
-module(error_path_SUITE).
2+
3+
-include_lib("common_test/include/ct.hrl").
4+
-include_lib("stdlib/include/assert.hrl").
5+
6+
-export([
7+
all/0,
8+
groups/0,
9+
init_per_suite/1,
10+
end_per_suite/1
11+
]).
12+
13+
-export([
14+
%% Connection errors
15+
h1_connect_refused/1,
16+
h2_connect_refused/1,
17+
h1_connect_timeout/1,
18+
h2_connect_timeout/1,
19+
h1_unknown_option/1,
20+
h2_unknown_option/1,
21+
22+
%% Close behavior
23+
h1_close_already_closed/1,
24+
h2_close_already_closed/1,
25+
h1_is_open_after_close/1,
26+
h2_is_open_after_close/1,
27+
28+
%% Malformed response handling
29+
h1_server_sends_garbage/1,
30+
h1_server_closes_mid_response/1,
31+
32+
%% Unified API errors
33+
unified_connect_refused/1,
34+
unified_unknown_protocol/1
35+
]).
36+
37+
%%====================================================================
38+
%% CT Callbacks
39+
%%====================================================================
40+
41+
all() ->
42+
[
43+
{group, connection_errors},
44+
{group, close_behavior},
45+
{group, malformed_responses},
46+
{group, unified_errors}
47+
].
48+
49+
groups() ->
50+
[
51+
{connection_errors, [parallel], [
52+
h1_connect_refused,
53+
h2_connect_refused,
54+
h1_connect_timeout,
55+
h2_connect_timeout,
56+
h1_unknown_option,
57+
h2_unknown_option
58+
]},
59+
{close_behavior, [parallel], [
60+
h1_close_already_closed,
61+
h2_close_already_closed,
62+
h1_is_open_after_close,
63+
h2_is_open_after_close
64+
]},
65+
{malformed_responses, [sequence], [
66+
h1_server_sends_garbage,
67+
h1_server_closes_mid_response
68+
]},
69+
{unified_errors, [parallel], [
70+
unified_connect_refused,
71+
unified_unknown_protocol
72+
]}
73+
].
74+
75+
init_per_suite(Config) ->
76+
Config.
77+
78+
end_per_suite(_Config) ->
79+
ok.
80+
81+
%%====================================================================
82+
%% Connection Errors
83+
%%====================================================================
84+
85+
h1_connect_refused(_Config) ->
86+
%% Port 1 is almost always not listening
87+
Result = gen_http_h1:connect(http, "127.0.0.1", 1, #{timeout => 500}),
88+
?assertMatch({error, {transport_error, {connect_failed, _}}}, Result).
89+
90+
h2_connect_refused(_Config) ->
91+
Result = gen_http_h2:connect(http, "127.0.0.1", 1, #{timeout => 500}),
92+
?assertMatch({error, {transport_error, {connect_failed, _}}}, Result).
93+
94+
h1_connect_timeout(_Config) ->
95+
%% 192.0.2.1 is TEST-NET, should be non-routable and time out
96+
Result = gen_http_h1:connect(http, "192.0.2.1", 80, #{timeout => 200}),
97+
?assertMatch({error, {transport_error, {connect_failed, _}}}, Result).
98+
99+
h2_connect_timeout(_Config) ->
100+
Result = gen_http_h2:connect(http, "192.0.2.1", 80, #{timeout => 200}),
101+
?assertMatch({error, {transport_error, {connect_failed, _}}}, Result).
102+
103+
h1_unknown_option(_Config) ->
104+
Result = gen_http_h1:connect(http, "localhost", 80, #{max_pipline => 5}),
105+
?assertEqual({error, {unknown_option, max_pipline}}, Result).
106+
107+
h2_unknown_option(_Config) ->
108+
Result = gen_http_h2:connect(http, "localhost", 80, #{max_concurrnt => 50}),
109+
?assertEqual({error, {unknown_option, max_concurrnt}}, Result).
110+
111+
%%====================================================================
112+
%% Close Behavior
113+
%%====================================================================
114+
115+
h1_close_already_closed(Config) ->
116+
Conn = make_h1_conn(Config),
117+
{ok, Conn2} = gen_http_h1:close(Conn),
118+
?assertNot(gen_http_h1:is_open(Conn2)),
119+
%% Closing again should be idempotent
120+
{ok, Conn3} = gen_http_h1:close(Conn2),
121+
?assertNot(gen_http_h1:is_open(Conn3)).
122+
123+
h2_close_already_closed(Config) ->
124+
Conn = make_h2_conn(Config),
125+
{ok, Conn2} = gen_http_h2:close(Conn),
126+
?assertNot(gen_http_h2:is_open(Conn2)),
127+
{ok, Conn3} = gen_http_h2:close(Conn2),
128+
?assertNot(gen_http_h2:is_open(Conn3)).
129+
130+
h1_is_open_after_close(Config) ->
131+
Conn = make_h1_conn(Config),
132+
?assert(gen_http_h1:is_open(Conn)),
133+
{ok, Conn2} = gen_http_h1:close(Conn),
134+
?assertNot(gen_http_h1:is_open(Conn2)).
135+
136+
h2_is_open_after_close(Config) ->
137+
Conn = make_h2_conn(Config),
138+
?assert(gen_http_h2:is_open(Conn)),
139+
{ok, Conn2} = gen_http_h2:close(Conn),
140+
?assertNot(gen_http_h2:is_open(Conn2)).
141+
142+
%%====================================================================
143+
%% Malformed Response Handling
144+
%%====================================================================
145+
146+
h1_server_sends_garbage(_Config) ->
147+
{ServerPort, ListenSock} = start_tcp_server(),
148+
{ok, Conn} = gen_http_h1:connect(http, "127.0.0.1", ServerPort, #{mode => passive}),
149+
150+
%% Send a request
151+
{ok, Conn2, _Ref} = gen_http_h1:request(Conn, <<"GET">>, <<"/">>, [], <<>>),
152+
153+
%% Accept and send garbage from server side
154+
{ok, ClientSock} = gen_tcp:accept(ListenSock, 2000),
155+
gen_tcp:send(ClientSock, <<"NOT-HTTP GARBAGE\r\n\r\n">>),
156+
gen_tcp:close(ClientSock),
157+
158+
%% Client should get a parse error or closed error
159+
Result = gen_http_h1:recv(Conn2, 0, 2000),
160+
case Result of
161+
{error, _Conn3, {protocol_error, _}, _} -> ok;
162+
{error, _Conn3, {transport_error, closed}, _} -> ok;
163+
{ok, _Conn3, _Responses} -> ok
164+
end,
165+
gen_tcp:close(ListenSock).
166+
167+
h1_server_closes_mid_response(_Config) ->
168+
{ServerPort, ListenSock} = start_tcp_server(),
169+
{ok, Conn} = gen_http_h1:connect(http, "127.0.0.1", ServerPort, #{mode => passive}),
170+
171+
%% Send a request
172+
{ok, Conn2, _Ref} = gen_http_h1:request(Conn, <<"GET">>, <<"/">>, [], <<>>),
173+
174+
%% Accept, send partial response, then close
175+
{ok, ClientSock} = gen_tcp:accept(ListenSock, 2000),
176+
gen_tcp:send(ClientSock, <<"HTTP/1.1 200 OK\r\ncontent-length: 1000\r\n\r\npartial">>),
177+
gen_tcp:close(ClientSock),
178+
179+
%% Client should eventually get a closed/error
180+
Result = gen_http_h1:recv(Conn2, 0, 2000),
181+
case Result of
182+
{ok, Conn3, Responses} ->
183+
%% May get partial data followed by close on next recv
184+
ct:pal("Got partial responses: ~p", [Responses]),
185+
Result2 = gen_http_h1:recv(Conn3, 0, 2000),
186+
case Result2 of
187+
{error, _, {transport_error, closed}, _} -> ok;
188+
{error, _, _, _} -> ok;
189+
{ok, _, _} -> ok
190+
end;
191+
{error, _Conn3, _Reason, _Responses} ->
192+
ok
193+
end,
194+
gen_tcp:close(ListenSock).
195+
196+
%%====================================================================
197+
%% Unified API Errors
198+
%%====================================================================
199+
200+
unified_connect_refused(_Config) ->
201+
Result = gen_http:connect(http, "127.0.0.1", 1, #{timeout => 500}),
202+
?assertMatch({error, {transport_error, {connect_failed, _}}}, Result).
203+
204+
unified_unknown_protocol(_Config) ->
205+
Result = gen_http:connect(https, "127.0.0.1", 1, #{
206+
timeout => 500,
207+
protocols => [http2]
208+
}),
209+
?assertMatch({error, _}, Result).
210+
211+
%%====================================================================
212+
%% Helpers
213+
%%====================================================================
214+
215+
start_tcp_server() ->
216+
{ok, ListenSock} = gen_tcp:listen(0, [binary, {packet, raw}, {active, false}, {reuseaddr, true}]),
217+
{ok, Port} = inet:port(ListenSock),
218+
{Port, ListenSock}.
219+
220+
make_h1_conn(_Config) ->
221+
{ServerPort, ListenSock} = start_tcp_server(),
222+
{ok, Conn} = gen_http_h1:connect(http, "127.0.0.1", ServerPort, #{mode => passive}),
223+
%% Accept the connection on server side (don't need to do anything with it)
224+
spawn(fun() ->
225+
{ok, _Sock} = gen_tcp:accept(ListenSock, 5000),
226+
timer:sleep(60000)
227+
end),
228+
put(listen_sock, ListenSock),
229+
Conn.
230+
231+
make_h2_conn(_Config) ->
232+
{ServerPort, ListenSock} = start_tcp_server(),
233+
%% H2 connect over plaintext will try to send preface, so we need
234+
%% a server that at least accepts the connection
235+
spawn(fun() ->
236+
case gen_tcp:accept(ListenSock, 5000) of
237+
{ok, Sock} ->
238+
%% Read the preface, send back a minimal SETTINGS frame
239+
Preface = <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>,
240+
case gen_tcp:recv(Sock, byte_size(Preface), 5000) of
241+
{ok, _} ->
242+
%% Also consume the client SETTINGS frame
243+
case gen_tcp:recv(Sock, 9, 5000) of
244+
{ok, <<Len:24, _:48>>} ->
245+
gen_tcp:recv(Sock, Len, 5000),
246+
%% Send empty SETTINGS + SETTINGS ACK
247+
EmptySettings = <<0:24, 4:8, 0:8, 0:32>>,
248+
SettingsAck = <<0:24, 4:8, 1:8, 0:32>>,
249+
gen_tcp:send(Sock, [EmptySettings, SettingsAck]),
250+
timer:sleep(60000);
251+
_ ->
252+
ok
253+
end;
254+
_ ->
255+
ok
256+
end;
257+
_ ->
258+
ok
259+
end
260+
end),
261+
%% Give the server a moment to start accepting
262+
timer:sleep(50),
263+
{ok, Conn} = gen_http_h2:connect(http, "127.0.0.1", ServerPort, #{mode => passive}),
264+
put(listen_sock, ListenSock),
265+
Conn.

0 commit comments

Comments
 (0)