Stream a response body to a sink instead of buffering it (#213 slice 1) - #214
Merged
Merged
Conversation
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
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
|
Rechecked eb8dccd. All four review findings are addressed correctly, including their regression coverage, and I found no new actionable issues. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice 1 of #213, the runtime half: a transport can hand a response body to the caller in pieces. No codegen — a
@streamingblob still generates a bufferedopal::Blob, which is slice 2.What
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.acceptis 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 inresponse.body, where the generated deserializer already reads it — pinned by a consumer test.writegets 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_bodytobuffer_body: read the header, ask the sink, then pump through a 16 KiB window. Every other transport inherits a defaultSendStreamingthat buffers and hands the body over in one piece — same delivery, same emptyresponse.body, no memory bound. So the API works everywhere and pays off where it matters, andLoopbackandSocketHttpClientneed no changes.Two things the rewrite preserves deliberately, since it now carries every Beast send:
max_response_bytesstill bounds the buffered branch, including refusing a declaredContent-Lengthover the cap before reading any of it (now an explicitparser.content_length()check rather than Beast'sbody_limit).Retries
SendWithRetriesgains 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.bodylike every other error document. Withmax_attempts = 1nothing can be discarded, so the caller'sacceptstands 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
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).boolto cancel.Interceptorand tested — a hook that logs bodies sees nothing rather than something truncated.Testing
runtime/tests/http/body_sink_test.cc(new): the shared contract onLoopbackandSocketHttpClient— 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 andacceptis 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 reachingBodySinkthrough@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=noexceptbuild passes.gradle build spotlessCheckpasses. 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_bodybehaves likestring_bodydid.Checklist
bazel test //...and(cd codegen && gradle build spotlessCheck)pass locally@streamingblob 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