Skip to content

Commit 410cba0

Browse files
authored
Return response headers from request_to_file in httpc adapter (#175)
The httpc {stream, Filename} option discards response headers and status code, returning only {ok, saved_to_file} for 2xx responses. This meant callers never received headers like etag, breaking conditional download support. Use async streaming ({stream, self}) instead, which delivers headers in stream_start while still streaming body chunks to disk without loading the full response into memory.
1 parent 77ab7e4 commit 410cba0

2 files changed

Lines changed: 177 additions & 6 deletions

File tree

src/hex_http_httpc.erl

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,12 @@ request_to_file(Method, URI, ReqHeaders, Body, Filename, AdapterConfig) when is_
3939
Method,
4040
Request,
4141
HTTPOptions,
42-
[{stream, unicode:characters_to_list(Filename)}],
42+
[{sync, false}, {stream, self}],
4343
Profile
4444
)
4545
of
46-
{ok, saved_to_file} ->
47-
{ok, {200, #{}}};
48-
{ok, {{_, StatusCode, _}, RespHeaders, _RespBody}} ->
49-
RespHeaders2 = load_headers(RespHeaders),
50-
{ok, {StatusCode, RespHeaders2}};
46+
{ok, RequestId} ->
47+
stream_to_file(RequestId, Filename);
5148
{error, Reason} ->
5249
{error, Reason}
5350
end.
@@ -56,6 +53,39 @@ request_to_file(Method, URI, ReqHeaders, Body, Filename, AdapterConfig) when is_
5653
%% Internal functions
5754
%%====================================================================
5855

56+
%% @private
57+
%% httpc streams 200/206 responses as messages and returns non-2xx as
58+
%% a normal response tuple. stream_start includes the response headers.
59+
stream_to_file(RequestId, Filename) ->
60+
receive
61+
{http, {RequestId, stream_start, Headers}} ->
62+
{ok, File} = file:open(Filename, [write, binary]),
63+
case stream_body(RequestId, File) of
64+
ok ->
65+
ok = file:close(File),
66+
{ok, {200, load_headers(Headers)}};
67+
{error, Reason} ->
68+
ok = file:close(File),
69+
{error, Reason}
70+
end;
71+
{http, {RequestId, {{_, StatusCode, _}, RespHeaders, _RespBody}}} ->
72+
{ok, {StatusCode, load_headers(RespHeaders)}};
73+
{http, {RequestId, {error, Reason}}} ->
74+
{error, Reason}
75+
end.
76+
77+
%% @private
78+
stream_body(RequestId, File) ->
79+
receive
80+
{http, {RequestId, stream, BinBodyPart}} ->
81+
ok = file:write(File, BinBodyPart),
82+
stream_body(RequestId, File);
83+
{http, {RequestId, stream_end, _Headers}} ->
84+
ok;
85+
{http, {RequestId, {error, Reason}}} ->
86+
{error, Reason}
87+
end.
88+
5989
%% @private
6090
http_options(URI, AdapterConfig) ->
6191
HTTPOptions0 = maps:get(http_options, AdapterConfig, []),

test/hex_http_httpc_SUITE.erl

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
-module(hex_http_httpc_SUITE).
2+
3+
-compile([export_all]).
4+
5+
all() ->
6+
[
7+
request_to_file_returns_headers,
8+
request_to_file_streams_large_body,
9+
request_to_file_returns_error_status
10+
].
11+
12+
init_per_suite(Config) ->
13+
application:ensure_all_started(inets),
14+
Config.
15+
16+
end_per_suite(_Config) ->
17+
ok.
18+
19+
request_to_file_returns_headers(Config) ->
20+
PrivDir = proplists:get_value(priv_dir, Config),
21+
Body = <<"hello world">>,
22+
Response = [
23+
"HTTP/1.1 200 OK\r\n"
24+
"content-length: ",
25+
integer_to_list(byte_size(Body)),
26+
"\r\n"
27+
"etag: \"abc123\"\r\n"
28+
"\r\n",
29+
Body
30+
],
31+
32+
{ok, ListenSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]),
33+
{ok, Port} = inet:port(ListenSock),
34+
35+
Self = self(),
36+
spawn_link(fun() ->
37+
{ok, Sock} = gen_tcp:accept(ListenSock),
38+
{ok, _} = gen_tcp:recv(Sock, 0, 5000),
39+
gen_tcp:send(Sock, Response),
40+
gen_tcp:close(Sock),
41+
Self ! server_done
42+
end),
43+
44+
Filename = filename:join(PrivDir, "download_200"),
45+
URI = iolist_to_binary(["http://localhost:", integer_to_list(Port), "/test"]),
46+
47+
{ok, {200, Headers}} =
48+
hex_http_httpc:request_to_file(get, URI, #{}, undefined, Filename, #{}),
49+
50+
<<"\"abc123\"">> = maps:get(<<"etag">>, Headers),
51+
{ok, Body} = file:read_file(Filename),
52+
53+
receive
54+
server_done -> ok
55+
after 5000 ->
56+
error(server_timeout)
57+
end,
58+
gen_tcp:close(ListenSock).
59+
60+
%% Send a body large enough that httpc delivers it as multiple stream
61+
%% messages to verify the receive loop reassembles the file correctly.
62+
request_to_file_streams_large_body(Config) ->
63+
PrivDir = proplists:get_value(priv_dir, Config),
64+
%% 1 MB body — well above httpc's internal chunk size
65+
Body = binary:copy(<<"x">>, 1024 * 1024),
66+
Response = [
67+
"HTTP/1.1 200 OK\r\n"
68+
"content-length: ",
69+
integer_to_list(byte_size(Body)),
70+
"\r\n\r\n",
71+
Body
72+
],
73+
74+
{ok, ListenSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]),
75+
{ok, Port} = inet:port(ListenSock),
76+
77+
Self = self(),
78+
spawn_link(fun() ->
79+
{ok, Sock} = gen_tcp:accept(ListenSock),
80+
{ok, _} = gen_tcp:recv(Sock, 0, 5000),
81+
gen_tcp:send(Sock, Response),
82+
gen_tcp:close(Sock),
83+
Self ! server_done
84+
end),
85+
86+
Filename = filename:join(PrivDir, "download_large"),
87+
URI = iolist_to_binary(["http://localhost:", integer_to_list(Port), "/test"]),
88+
89+
{ok, {200, _Headers}} =
90+
hex_http_httpc:request_to_file(get, URI, #{}, undefined, Filename, #{}),
91+
92+
{ok, Body} = file:read_file(Filename),
93+
94+
receive
95+
server_done -> ok
96+
after 5000 ->
97+
error(server_timeout)
98+
end,
99+
gen_tcp:close(ListenSock).
100+
101+
%% httpc async streaming only streams 2xx responses as messages.
102+
%% Non-2xx responses return the normal response tuple with the real
103+
%% status code and headers.
104+
request_to_file_returns_error_status(Config) ->
105+
PrivDir = proplists:get_value(priv_dir, Config),
106+
assert_error_status(
107+
404, PrivDir, "HTTP/1.1 404 Not Found\r\ncontent-length: 9\r\n\r\nnot found"
108+
),
109+
assert_error_status(
110+
403, PrivDir, "HTTP/1.1 403 Forbidden\r\ncontent-length: 9\r\n\r\nforbidden"
111+
),
112+
assert_error_status(
113+
500, PrivDir, "HTTP/1.1 500 Internal Server Error\r\ncontent-length: 6\r\n\r\nerror!"
114+
),
115+
ok.
116+
117+
assert_error_status(ExpectedStatus, PrivDir, Response) ->
118+
{ok, ListenSock} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]),
119+
{ok, Port} = inet:port(ListenSock),
120+
121+
Self = self(),
122+
spawn_link(fun() ->
123+
{ok, Sock} = gen_tcp:accept(ListenSock),
124+
{ok, _} = gen_tcp:recv(Sock, 0, 5000),
125+
gen_tcp:send(Sock, Response),
126+
gen_tcp:close(Sock),
127+
Self ! server_done
128+
end),
129+
130+
Filename = filename:join(PrivDir, "download_" ++ integer_to_list(ExpectedStatus)),
131+
URI = iolist_to_binary(["http://localhost:", integer_to_list(Port), "/test"]),
132+
133+
{ok, {ExpectedStatus, _Headers}} =
134+
hex_http_httpc:request_to_file(get, URI, #{}, undefined, Filename, #{}),
135+
136+
receive
137+
server_done -> ok
138+
after 5000 ->
139+
error(server_timeout)
140+
end,
141+
gen_tcp:close(ListenSock).

0 commit comments

Comments
 (0)