Stream @streaming blob response payloads from generated clients (#213 slice 2) - #216
Merged
Merged
Conversation
Slice 2. An operation whose response @httpPayload targets a @streaming blob takes an opal::http::BodyWriter alongside its input: the bytes go to the writer as they arrive and the member is left empty. The generated code owns the sink's accept gate and keys it on 2xx. A sink takes payloads and nothing else — an error document has to stay buffered so the typed <Operation>Errors path can deserialize it, and a 3xx body is not the payload either. The writer is defaulted to nullptr, which slice 1 already reads as "no sink at all": one generated code path serves both, client.Download(input) still buffers into the member, and adding @streaming to a model breaks no caller. The member stays on the output structure for the same reason it has to — the structure is shared with the server generator, which still returns the whole payload, and Smithy requires @required or @default on a streaming member, so it is a plain opal::Blob rather than an optional. Only an @httpPayload blob on an HTTP-binding protocol streams. Elsewhere the blob is base64 inside a document that must be parsed whole before the member exists, so it stays buffered exactly as before — no goldens move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
Cursor's review on #216 caught a real inconsistency: the accept gate was 2xx, but the client's success check below it is the modeled @http code, or 2xx/3xx under @httpResponseCode. Both directions were wrong. A modeled 3xx that carries a payload is a success the client returns, so a 2xx gate buffered the payload into the member and still returned success — the caller asked for streaming and silently got neither the bytes nor an error. And for a static-code operation an unexpected 2xx streamed before the status check rejected it, leaving the error path an empty body to parse. Emitting the success condition itself makes the two states line up by construction: a success streamed, a failure left its body where the error parser reads it. DownloadDynamic pairs with Download on the redirector model the way ResolveDynamic pairs with Resolve — same response, different branch through the generator — so the @httpResponseCode arm has out-of-tree coverage: a modeled 302 carrying the payload streams it. That test fails against the old gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
Beyoncé-rule audit of the slice-2 diff found two behaviors the docs promise and nothing tested, and one the docs promise that cannot happen. Tested now: an RPC protocol leaves a @streaming blob response buffered and its client byte-identical — no writer parameter, no sink on the Send helper, no payload_sink in the body. The alternative failure mode is a writer parameter that silently never fires. Also paired the static-code gate with the status check it must equal, the way the @httpResponseCode test already does: if the two predicates ever diverge, either a rejected status was streamed or an accepted one buffered. Removed: "a @streaming blob bound anywhere but @httpPayload stays buffered." Smithy refuses to assemble such a model — "must have the @httpPayload trait, as service has a protocol that supports @httpPayload" — so the claim was vacuous on the binding protocols. The real scope is a response payload there, with request payloads and the RPC protocols buffered; README, CHANGELOG, generated-types and production-guide now say that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
Cursor's second review finding on #216, and it is real: an operation may model a success status that SendWithRetries classifies as transient — @http(code: 503) plus the HttpResponseCodeSemantics suppression the redirect fixture already uses for 302. The generated gate and status check then both call 503 a success and stream on it, while the retry loop retries it and withholds it from the sink on every attempt. The call returns success with the payload buffered into the member and the caller's writer never invoked. Teaching the retry layer each operation's success predicate would change a public runtime API and every client's retry behavior, and the wasted retries on such a model are a pre-existing defect independent of streaming. So this slice refuses the model by name, the way EventStreamCodeGen.validate refuses the event-stream scope edges, rather than emitting a writer that cannot fire. The diagnostic names the operation, the status, and the fix (@httpResponseCode carries the status at runtime instead). Only the static-code arm can collide: @httpResponseCode success is 2xx/3xx, which shares nothing with the retryable set, and the RPC protocols do not stream. A modeled 302 is unaffected and still streams, pinned by aNonRetryableModeledSuccessStatusStillStreams. The refusal needs the retryable status set in Java, whose source of truth is opal::RetryableStatus in C++. RetryableStatusMirrorTest reads retry.cc and compares, so the copy is allowed to exist only while it stays a copy — dropping 503 from the Java set fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
CodeQL flagged Integer.parseInt in the mirror test as a possible NumberFormatException. It cannot throw — the pattern constrains the group to exactly three digits — so the suggested try/catch would be an unreachable branch no test can reach, which is worse than the finding. Comparing the digit tokens both sides are written with is exact in the same way and has no parse to guard, so the shape CodeQL objects to is gone rather than wrapped. Added an emptiness assertion while here: a pattern that matched the function but no statuses would otherwise have compared two empty sets and passed. Still mutation-checked: dropping 503 from the Java set fails the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ
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.
What
Closes slice 2 of #213: an operation whose response
@httpPayloadtargets a@streamingblob takes anopal::http::BodyWriteralongside its input. The bytes go to the writer as they arrive; the member is left empty.Decisions worth reviewing:
status == <modeled @http code>, or200 <= status < 400when the status comes from@httpResponseCode. Success streams the payload; everything else stays buffered, so a modeled error still deserializes into the typed<Operation>Errorslisting from its own body. (This started out as a plain 2xx gate; Cursor's review caught that it disagreed with the client's success check in both directions — a modeled 3xx carrying a payload was silently buffered while still returning success, and an unexpected 2xx on a static-code operation was streamed before the status check rejected it, starving the error parser. Fixed in 1365f54.)@http(code: 503)with theHttpResponseCodeSemanticssuppression makes 503 an operation's success, butSendWithRetriesImplwraps the sink's accept with!RetryableStatus(status) && accept(...)— so the writer could never fire, and the call would return success with the payload buffered. Cursor's second finding. Teaching the retry layer each operation's success predicate changes a public runtime API and every client's retry behavior, and the wasted retries on such a model are pre-existing and independent of streaming, so this slice refuses the model by name instead (c36761b), the wayEventStreamCodeGen.validaterefuses the event-stream scope edges. Only a static modeled code can collide:@httpResponseCodesuccess is 2xx/3xx, disjoint from {429, 500, 502, 503, 504}. A modeled 302 — the redirect fixture's own case — still streams.nullptr. Slice 1 already reads a sink missing either callback as no sink at all, so one generated code path serves both:client.Download(input)still buffers into the member, and adding@streamingto a model breaks no caller (existing generated smoke/protocol/integration tests call the unqualified form and still compile).@streamingblob bodies: deliver a response in pieces instead of buffering it #213, which had the output omit it. The structure is shared with the server generator, which still returns the whole payload — removing the member would have dragged the deferred server half into this PR. Smithy also requires@requiredor@defaulton a streaming member, so it is a plainopal::Blobrather than an optional: streamed means empty, not absent.@httpPayloadbinding on a streaming blob wherever the protocol supports it, so there is no in-between case to handle there. On the RPC protocols the blob is base64 inside the one document and stays a bufferedopal::Blob(ProtocolGenerator.supportsStreamingBlobPayloads()returns false); so does a request payload, which would need chunked request framing the http1 codec refuses on purpose.Sendhelper only grows its sink parameter for services that actually stream one, so every existing generated client is byte-identical, and no fixture or protocol-test model trips the refusal.The runtime side is one line of surface:
opal::http::BodyWriter, a name for the write half ofBodySink, so a caller passing only a writer does not have to spell out anacceptthe protocol already decides.Testing
Codegen shape tests, each written red first:
streamingBlobOutputsTakeAWriterAndAreGatedOnSuccess— the signature, the static-code gate (status == 200), the writer wiring, the null-writer branch, and that the gate equals the status check below it.aStreamingPayloadUnderHttpResponseCodeStreamsEverySuccessStatus— the same pairing for the@httpResponseCodearm.anRpcProtocolLeavesAStreamingBlobResponseBuffered— the protocol gate: no writer, no sink onSend, nopayload_sink.aStreamingPayloadOnARetryableSuccessStatusIsRefused/aNonRetryableModeledSuccessStatusStillStreams— the refusal and its bound.RetryableStatusMirrorTest— the refusal needs the retryable-status set in Java, duplicatingopal::RetryableStatus; this parsesruntime/src/client/retry.ccand compares, so the copy is allowed to exist only while it stays a copy.Out-of-tree acceptance (
examples/bazel-consumer/response_sink_acceptance_test.cc), through a generated client built from an endpoint alone against a generated server:Download): the payload reaches the writer with an order-sensitive digest and the member stays empty; omitting the writer buffers the same bytes into the member; a modeled 404 deserializes intoDownloadErrors::NoSuchSlugwithout touching the writer; a writer returning false fails the call non-retryably with no backoff.@httpResponseCodearm (DownloadDynamic, paired withDownloadthe wayResolveDynamicis withResolve): a modeled 302 carrying the payload streams it, an ordinary 200 streams it, a 404 stays buffered for the typed-error path.Mutation-checked, not just asserted:
accept = trueturns the modeled-error test red; the old 2xx gate turnsAModeledRedirectCarryingThePayloadStillStreamsItred; dropping 503 from the Java status set turns the mirror test red.bazel test --config=werror //...— 130/130. Consumer module, same flags (minus the Beast targets a download-blocking proxy cannot run) — 14/14.gradle test spotlessCheckgreen,generateFixtures generateProtocolTestsproduces no diff. clang-format and buildifier clean.Known gaps
Checklist
bazel test //...and(cd codegen && gradle build spotlessCheck)pass locally@streamingblob bodies: deliver a response in pieces instead of buffering it #213's slicing🤖 Generated with Claude Code
https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ