Skip to content

Stream a response body to a sink instead of buffering it (#213 slice 1) - #214

Merged
aaylward merged 3 commits into
mainfrom
claude/kind-fermi-elfabg-213-sink
Sep 11, 2026
Merged

aaylward merged 3 commits into
mainfrom
claude/kind-fermi-elfabg-213-sink

Conversation

@aaylward

Copy link
Copy Markdown
Collaborator

Slice 1 of #213, the runtime half: a transport can hand a response body to the caller in pieces. No codegen — a @streaming blob still generates a buffered opal::Blob, which is slice 2.

What

std::ofstream out("export.pgn", std::ios::binary);
const opal::http::BodySink to_file{
    .accept = [](int status, const opal::http::Headers&) { return status == 200; },
    .write = [&](std::string_view piece) { return out.write(piece.data(), piece.size()).good(); },
};
auto response = transport->SendStreaming(request, to_file);   // response->body is empty

This is what max_response_bytes (#212) could not be. That cap bounds what the process holds; a download whose size the service does not bound needs the process not to hold it.

accept is asked once per response, after the status and headers and before any body byte. That timing is the whole design: early enough to keep the body out of memory, late enough to know what the body is. Per-response means a sink that takes payloads leaves a 404's error document in response.body, where the generated deserializer already reads it — pinned by a consumer test.

write gets pieces in order, never empty, valid for the call only. False aborts: non-retryable error, connection dropped rather than pooled.

Beast is the transport that actually streams

Its read path moves from string_body to buffer_body: read the header, ask the sink, then pump through a 16 KiB window. Every other transport inherits a default SendStreaming that buffers and hands the body over in one piece — same delivery, same empty response.body, no memory bound. So the API works everywhere and pays off where it matters, and Loopback and SocketHttpClient need no changes.

Two things the rewrite preserves deliberately, since it now carries every Beast send:

  • max_response_bytes still bounds the buffered branch, including refusing a declared Content-Length over the cap before reading any of it (now an explicit parser.content_length() check rather than Beast's body_limit).
  • A streamed body is not bounded by it. The cap is about what this process holds, and here it holds nothing.

Retries

SendWithRetries gains a sink overload and withholds retryable statuses from the sink while retries are enabled. Streaming a 503's error document and then retrying would leave the sink holding that body followed by the real one, with no way to take the first back.

The rule holds on the last attempt too, deliberately: which attempt produced a 503 is not something a caller should have to reason about, and such a body is small and arrives in response.body like every other error document. With max_attempts = 1 nothing can be discarded, so the caller's accept stands unaltered. A failure after the sink took bytes is non-retryable, so the loop stops there.

I had this one wrong in the first draft — the doc comment promised "while another attempt may follow" and the code withheld on every attempt. The test caught the disagreement. The code's behavior is the better of the two, so the comment and test moved to it rather than the other way around.

Answers to the open questions in #213

  1. Beast only. The socket client keeps refusing transfer-encoding; teaching it chunked decoding is a security-sensitive change that wants its own hostile bank and fuzz target, and it is test/reference-only (ADR-0006).
  2. Push callback, with bool to cancel.
  3. Yes, interceptors see a headers-only response. Documented on Interceptor and tested — a hook that logs bodies sees nothing rather than something truncated.
  4. The cap does not apply to a streamed body, and still applies to a declined one.
  5. Left to slice 2.

Testing

  • runtime/tests/http/body_sink_test.cc (new): the shared contract on Loopback and SocketHttpClient — accepted, declined, aborted, empty body, half a sink is no sink, a transport failure never reaches the sink.
  • beast_client_test.cc: more than one piece (the observable difference between streaming and having streamed nothing — 1 MiB through a 16 KiB window), a 64 KiB body streaming past a 16-byte cap while the same response declined trips it, abort leaves the client usable, and three streamed round trips reuse the pooled connection.
  • retry_test.cc: the sink never sees a 503's body and accept is not even consulted for one; the same on the final attempt; with retries disabled the caller's decision stands; a non-retryable failure ends the loop; interceptors see an empty body.
  • examples/bazel-consumer/response_sink_acceptance_test.cc (new): a consumer module reaching BodySink through @opal_cpp//runtime:http, pulling a 512 KiB blob payload off a generated server and checking a digest, and leaving a modeled error's document intact.
  • bazel test //... --config=werror: 130 pass. Consumer module: 17 pass. --config=noexcept build passes. gradle build spotlessCheck passes. Goldens untouched (no emitter change). clang-format, clang-tidy, buildifier clean.

The existing 129 tests all run through the rewritten Beast read path, which is most of the confidence that buffer_body behaves like string_body did.

Checklist

  • Tests added/updated for the change
  • bazel test //... and (cd codegen && gradle build spotlessCheck) pass locally
  • Formatting clean (clang-format, buildifier, spotless)
  • Architectural decisions recorded as an ADR (not applicable: an additive method on an existing interface; the design record is @streaming blob bodies: deliver a response in pieces instead of buffering it #213 and the research doc's §6 status note)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ


Generated by Claude Code

HttpClient::SendStreaming takes a BodySink: an accept(status, headers)
asked once per response, and a write(piece) handed the body in order as
it arrives. An accepted response comes back with its status and headers
and an empty body.

This is what max_response_bytes could not be. That cap bounds what the
process holds; a download whose size the service does not bound needs
the process not to hold it at all.

BeastHttpClient streams for real: its read path moves to buffer_body,
reading the header first so the sink can be asked before any body byte,
then pumping pieces through a 16 KiB window. Every other transport
inherits a default that buffers and hands the body over in one piece, so
the contract holds everywhere and only the memory bound follows the
transport. The cap keeps bounding the buffered branch, including the
early refusal of a declared Content-Length over it.

accept() is per response so a sink that takes payloads leaves a modeled
error's document in response.body, where the generated deserializer
reads it.

SendWithRetries gains an overload taking a sink and withholds retryable
statuses from it while retries are enabled: streaming a 503's error
document and then retrying would hand the sink that body followed by the
real one with no way to take the first back. The rule holds on the last
attempt too — which attempt produced a 503 is not something a caller
should have to reason about — and a failure after the sink took bytes is
not retryable, so the loop stops there.

Generated clients do not expose a sink yet; that is slice 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
clang-tidy's bugprone-implicit-widening-of-multiplication-result: the
array bound 16 * 1024 multiplies two ints and widens the result. The
constant wanted a name and a comment anyway.

Found by CI's lint job, which installs Boost and so tidies the Beast
transport; the local sweep skips that file for want of the headers.
Reproduced here by pointing clang-tidy at Bazel's own boost.* module
include dirs, which is how the fix was verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
Comment thread runtime/include/opal/http/transport.h Outdated
Comment thread runtime/src/http/beast_transport.cc Outdated
Comment thread runtime/src/http/beast_transport.cc Outdated
Comment thread runtime/src/http/beast_transport.cc Outdated
Contain a throwing sink in the fallback SendStreaming. Sink callbacks
are caller code the runtime invokes on its completion path, so ADR-0003
applies; Beast already contained them, which meant the transport
underneath decided whether a throwing sink was a failure or a crash.

Do not treat acceptance as bytes taken. A peer that sends headers and
then vanishes has handed the sink nothing, so the failure stays
retryable; only a successful write closes that door. The out-param is
now sink_took_bytes and says what it means.

Give the response one absolute deadline. Reading the body in windows
means several reads where there was one, and re-arming the timer for
each turned request_timeout_ms into an idle timeout: a peer dripping a
byte before every reset could hold a synchronous call open for as long
as it liked.

Rebuild the returned headers from the finished message. Beast folds
chunked trailer fields in as the body is read, so copying headers at the
header phase dropped them; accept() still sees only what had arrived
when it was asked.

Each fix has a test that fails without it, checked by reverting the four
changes one at a time. Three need wire shapes BeastServerTransport does
not emit, so the Beast suite grows a scripted raw peer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
@cursor

cursor Bot commented Sep 11, 2026

Copy link
Copy Markdown

Rechecked eb8dccd. All four review findings are addressed correctly, including their regression coverage, and I found no new actionable issues.

@aaylward
aaylward merged commit cf312e5 into main Sep 11, 2026
16 checks passed
@aaylward
aaylward deleted the claude/kind-fermi-elfabg-213-sink branch September 11, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants