Skip to content

Stream @streaming blob response payloads from generated clients (#213 slice 2) - #216

Merged
aaylward merged 5 commits into
mainfrom
claude/kind-fermi-elfabg-213-slice2
Sep 12, 2026
Merged

Stream @streaming blob response payloads from generated clients (#213 slice 2)#216
aaylward merged 5 commits into
mainfrom
claude/kind-fermi-elfabg-213-slice2

Conversation

@aaylward

@aaylward aaylward commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

What

Closes slice 2 of #213: 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; the member is left empty.

auto downloaded = client.Download(DownloadInput{.slug = "big"}, [&](std::string_view piece) {
  return out.write(piece.data(), piece.size()).good();
});
// downloaded->etag deserializes as usual; downloaded->content is empty.

Decisions worth reviewing:

  • The accept gate is the generator's, and it is the operation's own success conditionstatus == <modeled @http code>, or 200 <= status < 400 when the status comes from @httpResponseCode. Success streams the payload; everything else stays buffered, so a modeled error still deserializes into the typed <Operation>Errors listing 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.)
  • A modeled success status the retry layer retries is refused at generation time. @http(code: 503) with the HttpResponseCodeSemantics suppression makes 503 an operation's success, but SendWithRetriesImpl wraps 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 way EventStreamCodeGen.validate refuses the event-stream scope edges. Only a static modeled code can collide: @httpResponseCode success is 2xx/3xx, disjoint from {429, 500, 502, 503, 504}. A modeled 302 — the redirect fixture's own case — still streams.
  • The writer is defaulted to 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 @streaming to a model breaks no caller (existing generated smoke/protocol/integration tests call the unqualified form and still compile).
  • The member stays on the output structure. This deviates from the API sketch posted on @streaming blob 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 @required or @default on a streaming member, so it is a plain opal::Blob rather than an optional: streamed means empty, not absent.
  • Scope is a response payload on an HTTP-binding protocol. Smithy itself forces the @httpPayload binding 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 buffered opal::Blob (ProtocolGenerator.supportsStreamingBlobPayloads() returns false); so does a request payload, which would need chunked request framing the http1 codec refuses on purpose.
  • No goldens move. The Send helper 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 of BodySink, so a caller passing only a writer does not have to spell out an accept the 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 @httpResponseCode arm.
  • anRpcProtocolLeavesAStreamingBlobResponseBuffered — the protocol gate: no writer, no sink on Send, no payload_sink.
  • aStreamingPayloadOnARetryableSuccessStatusIsRefused / aNonRetryableModeledSuccessStatusStillStreams — the refusal and its bound.
  • RetryableStatusMirrorTest — the refusal needs the retryable-status set in Java, duplicating opal::RetryableStatus; this parses runtime/src/client/retry.cc and 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:

  • Static-code arm (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 into DownloadErrors::NoSuchSlug without touching the writer; a writer returning false fails the call non-retryably with no backoff.
  • @httpResponseCode arm (DownloadDynamic, paired with Download the way ResolveDynamic is with Resolve): 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 = true turns the modeled-error test red; the old 2xx gate turns AModeledRedirectCarryingThePayloadStillStreamsIt red; 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 spotlessCheck green, generateFixtures generateProtocolTests produces no diff. clang-format and buildifier clean.

Known gaps

  • The "unexpected 2xx on a static-code operation" arm has no runtime test: the generated server always answers the modeled code, so provoking it needs a hand-rolled server. The gate now being the success condition itself is the guard, pinned by the paired assertions above.
  • Retrying a modeled non-2xx success status (burning attempts on a modeled 503) is a pre-existing retry-layer defect, streaming or not. Out of scope here; worth its own issue.

Checklist

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jj5X2fKdgrYurHmbwLzUiQ

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
@aaylward
aaylward merged commit 6bb66f1 into main Sep 12, 2026
16 checks passed
@aaylward
aaylward deleted the claude/kind-fermi-elfabg-213-slice2 branch September 12, 2026 15:43
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