From 3040dd7694abe6411554838dd07b460ae5b215d0 Mon Sep 17 00:00:00 2001 From: Ruslan Levond Date: Fri, 10 Jul 2026 10:23:13 +0100 Subject: [PATCH] Honor caller http_opts (recv_timeout) on the streaming path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming path (`EventStream.stream_objects!/3`) called hackney with a hard-coded `@hackney_options` and discarded the `config[:http_opts]` the caller passes (the non-streaming path forwards these via ExAws.Request). As a result streaming always used hackney's default `recv_timeout` (5s) — an idle/first-byte timeout — so a slow model response was dropped with `{:closed, :timeout}` in the initial receive regardless of the timeout the caller configured, yielding an empty stream. Merge caller `:http_opts` (recv_timeout/connect_timeout/pool) into the hackney options via a testable `hackney_options/1`; async-streaming defaults win so `async: :once` can't be disabled by caller opts. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/ex_aws/bedrock/event_stream.ex | 22 +++++++++++++++++++- test/ex_aws/bedrock/event_stream_test.exs | 25 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/ex_aws/bedrock/event_stream.ex b/lib/ex_aws/bedrock/event_stream.ex index 1816499..8f3a495 100644 --- a/lib/ex_aws/bedrock/event_stream.ex +++ b/lib/ex_aws/bedrock/event_stream.ex @@ -57,8 +57,10 @@ defmodule ExAws.Bedrock.EventStream do encoded_data ) + hackney_opts = hackney_options(config) + request_fun = fn [] -> - {:ok, ref} = :hackney.post(url, full_headers, encoded_data, @hackney_options) + {:ok, ref} = :hackney.post(url, full_headers, encoded_data, hackney_opts) receive do {:hackney_response, ^ref, {:status, 200, _reason}} -> @@ -104,6 +106,24 @@ defmodule ExAws.Bedrock.EventStream do Stream.flat_map(stream, &decode_chunk/1) end + @doc """ + Builds the hackney options for the streaming request. + + Merges caller-provided options from the ExAws config `:http_opts` (e.g. + `recv_timeout`, `connect_timeout`, `pool`) on top of the async-streaming + defaults. Without this, the stream would always use hackney's built-in + `recv_timeout` (5s) and drop slow responses regardless of the timeout the + caller configured. + + The streaming defaults win on conflicting keys, so the async-streaming mode + (`async: :once`) can't be accidentally disabled by caller options. + """ + def hackney_options(config) do + config + |> Map.get(:http_opts, []) + |> Keyword.merge(@hackney_options) + end + defp verify_event_stream!(headers) do verify_header!(headers, "Content-Type", @content_type) end diff --git a/test/ex_aws/bedrock/event_stream_test.exs b/test/ex_aws/bedrock/event_stream_test.exs index 2977bc0..b5603c4 100644 --- a/test/ex_aws/bedrock/event_stream_test.exs +++ b/test/ex_aws/bedrock/event_stream_test.exs @@ -34,6 +34,31 @@ defmodule ExAws.Bedrock.EventStreamTest do end end + describe "hackney_options/1" do + test "defaults to async streaming options when no http_opts are given" do + assert EventStream.hackney_options(%{}) == [async: :once] + end + + test "honors caller recv_timeout / connect_timeout / pool from :http_opts" do + opts = + EventStream.hackney_options(%{ + http_opts: [recv_timeout: 600_000, connect_timeout: 10_000, pool: :ex_aws] + }) + + assert Keyword.get(opts, :async) == :once + assert Keyword.get(opts, :recv_timeout) == 600_000 + assert Keyword.get(opts, :connect_timeout) == 10_000 + assert Keyword.get(opts, :pool) == :ex_aws + end + + test "streaming defaults win so async: :once cannot be disabled by caller opts" do + opts = EventStream.hackney_options(%{http_opts: [async: false, recv_timeout: 1_000]}) + + assert Keyword.get(opts, :async) == :once + assert Keyword.get(opts, :recv_timeout) == 1_000 + end + end + setup_all do # Claude 3.5 Sonnet Multi-Chunk response multipart_chunk =