Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,21 @@ application:ensure_all_started(hackney).

### Simple GET

```erlang
{ok, 200, Headers, Ref} = hackney:get(<<"https://httpbin.org/get">>).
```

### Reading the Body
The response body is returned directly in the response tuple:

```erlang
{ok, Body} = hackney:body(Ref).
{ok, 200, Headers, Body} = hackney:get(<<"https://httpbin.org/get">>).
```

### One-Step Request with Body
### Reading the Body of a Streamed Request

Use `with_body` to get the body directly:
When you stream the request body, `start_response/1` returns a connection
PID. Read the full response body with `hackney:body/1` (or `stream_body/1`
for chunk-by-chunk):

```erlang
{ok, 200, Headers, Body} = hackney:get(URL, [], <<>>, [with_body]).
{ok, Status, RespHeaders, ConnPid} = hackney:start_response(ConnPid),
{ok, Body} = hackney:body(ConnPid).
```

## POST Requests
Expand Down Expand Up @@ -108,8 +107,8 @@ hackney:get(URL, [], <<>>, [{pool, false}]).

```erlang
case hackney:get(URL) of
{ok, Status, Headers, Ref} ->
{ok, Body} = hackney:body(Ref);
{ok, Status, Headers, Body} ->
handle_response(Status, Headers, Body);
{error, timeout} ->
handle_timeout();
{error, Reason} ->
Expand Down
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
`set_owner` race and falls through to a fresh connection instead of crashing
on a bad match, and an async ownership handoff to an already-closed pooled
connection stops it promptly so the pool drops it from rotation.
- Expose `hackney:body/1,2` and `hackney:stream_body/1` again so the response
body can be read after `start_response/1` in streaming body mode (#849).
The migration guide and examples referenced these but they were not
exported.

4.0.1 - 2026-05-25
------------------
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ Stream request bodies for uploads:
hackney:send_body(Ref, <<"chunk 1">>),
hackney:send_body(Ref, <<"chunk 2">>),
hackney:finish_send_body(Ref),
{ok, Status, _, Ref} = hackney:start_response(Ref).
{ok, Status, _, Ref} = hackney:start_response(Ref),
{ok, Body} = hackney:body(Ref).
```

Sync responses return the full body directly. To receive a large response
Expand Down
2 changes: 1 addition & 1 deletion guides/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

- **Response format**: Body is now always returned directly in the response
- **`with_body` option**: Deprecated and ignored
- **`hackney:body/1,2` and `hackney:stream_body/1`**: Deprecated - use async mode for streaming
- **`hackney:body/1,2` and `hackney:stream_body/1`**: Not needed for regular requests (the body is returned directly), but still available to read the response after `start_response/1` in streaming body mode
- **Async mode**: Now works consistently across HTTP/1.1, HTTP/2, and HTTP/3
- **Metrics removed**: `hackney_metrics` and the prometheus/dummy
backends are gone. Metrics are now user-supplied middleware. See the
Expand Down
18 changes: 18 additions & 0 deletions src/hackney.erl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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
Expand Down Expand Up @@ -562,6 +563,23 @@ finish_send_body(ConnPid) when is_pid(ConnPid) ->
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
%%====================================================================
Expand Down
18 changes: 17 additions & 1 deletion test/hackney_integration_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ all_tests() ->
fun test_frees_manager_ets_when_body_is_in_response/0,
fun test_307_redirect_pool_cleanup/0,
fun iolist_body_request/0,
fun json_encode_body_request/0].
fun json_encode_body_request/0,
fun streamed_request_body_read/0].

http_requests_test_() ->
{setup,
Expand Down Expand Up @@ -278,6 +279,21 @@ test_custom_host_headers() ->
ReqHeaders = maps:get(<<"headers">>, Obj),
?assertEqual(<<"myhost.com">>, maps:get(<<"host">>, ReqHeaders)).

%% #849: after streaming the request body, hackney:body/1 reads the full
%% response body from the connection PID returned by start_response/1.
streamed_request_body_read() ->
URL = url(<<"/post">>),
Headers = [{<<"content-type">>, <<"text/plain">>}],
{ok, ConnPid} = hackney:request(post, URL, Headers, stream, [{pool, false}]),
ok = hackney:send_body(ConnPid, <<"hello ">>),
ok = hackney:send_body(ConnPid, <<"world">>),
ok = hackney:finish_send_body(ConnPid),
{ok, 200, _RespHeaders, ConnPid} = hackney:start_response(ConnPid),
{ok, RespBody} = hackney:body(ConnPid),
?assert(is_binary(RespBody)),
Obj = jsx:decode(RespBody, [return_maps]),
?assertEqual(<<"hello world">>, maps:get(<<"data">>, Obj)).

test_frees_manager_ets_when_body_is_in_client() ->
%% In 3.x, body is always returned directly, so this test now verifies
%% that the body is returned and is binary, not a pid.
Expand Down
Loading