From 2030157c950b7b4f63a950fbe60ad33444a7a6f2 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 25 May 2026 18:30:37 +0200 Subject: [PATCH] fix: expose hackney:body/1,2 and stream_body/1 (#849) The migration guide and examples tell users to read the response body with hackney:body/1 after start_response/1 in streaming body mode, but the functions were not exported from the hackney module, so there was no public way to read the response after a streamed request. Re-export body/1, body/2 and stream_body/1 as thin wrappers over hackney_conn. Also fix the docs: GETTING_STARTED showed reading a regular GET body via body/Ref (the body is returned directly in 4.x) and referenced the removed with_body option; the migration summary called body/stream_body removed when they are still used after start_response. --- GETTING_STARTED.md | 21 ++++++++++----------- NEWS.md | 4 ++++ README.md | 3 ++- guides/MIGRATION.md | 2 +- src/hackney.erl | 18 ++++++++++++++++++ test/hackney_integration_tests.erl | 18 +++++++++++++++++- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 248680a1..4cd6abca 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -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 @@ -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} -> diff --git a/NEWS.md b/NEWS.md index 22765ee8..f9ec0175 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 ------------------ diff --git a/README.md b/README.md index f219fa13..b60cfad4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/guides/MIGRATION.md b/guides/MIGRATION.md index 0cb91610..a98932fa 100644 --- a/guides/MIGRATION.md +++ b/guides/MIGRATION.md @@ -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 diff --git a/src/hackney.erl b/src/hackney.erl index 0b988e89..96442aa8 100644 --- a/src/hackney.erl +++ b/src/hackney.erl @@ -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 @@ -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 %%==================================================================== diff --git a/test/hackney_integration_tests.erl b/test/hackney_integration_tests.erl index ef94a545..da044a36 100644 --- a/test/hackney_integration_tests.erl +++ b/test/hackney_integration_tests.erl @@ -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, @@ -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.