You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
muchq/MoonBase#1527 adds Lichess as a second chess platform, and its export endpoint is a stream:
GET /api/games/user/{username}?since={ms}&until={ms}
Accept: application/x-chess-pgn (default)
application/x-ndjson
Lichess's export endpoint returns a stream, not a JSON document […] Neither is a Smithy JSON response.
That issue names the decision it cannot make on its own:
Either the model gets a @streaming blob and the parsing lives outside it, or lichess_cpp is hand-written against the Beast transport.
Worth deciding deliberately rather than by default, since it is the first client here that will not be generated.
Today the second branch is the only one that works. @streaming on a blob member is ignored — it generates a fully buffered opal::Blob (docs/generated-types.md, and the README's Current limitations). ADR-0016 shipped event streams for @streamingunions and deferred this deliberately: "@streaming blobs remain unmodeled […] unbounded blob bodies are a different transport problem."
What a Lichess-shaped response meets today, one transport at a time:
Transport
What happens
SocketHttpClient
Refuses it. A stream is chunked, and the HTTP/1.1 reader rejects any transfer-encoding as a request-smuggling desync vector: http: transfer-encoding is not supported (runtime/src/http/http1.cc, pinned in http1_hostile_test.cc). Even framed until-EOF instead, the body still arrives as one std::string.
BeastHttpClient
Parses the chunked framing and hands back the whole body. A full game export is held in memory at once; with #212 it instead fails the call the moment it crosses max_response_bytes.
So the answer to #1527's open question — "Generated or hand-written client, given the stream?" — is forced rather than chosen, and the first hand-written client in that codebase becomes the precedent for the next one.
#212 (open) added ClientConfig::max_response_bytes, which bounds a buffered response. That was the missing bound, explicitly not the missing capability: it turns "unbounded memory" into "a failed call", which is the right default and still no way to read a stream.
Proposal
Two slices. The first is runtime-only and useful on its own; the second is what a model needs.
Slice 1 — a response body sink on the transport (6b)
A client transport hands body bytes to a caller-supplied sink instead of accumulating them, leaving HttpResponse::body empty.
The sink rides the request, never the wire — the same shape as peer_address and HttpResponse::operation: a field the transport reads and no serializer ever writes.
Retries stay transparent. A transport knows the status and headers before the body, so it buffers as it does today for a retryable status (429/5xx bodies are small) and streams only the final answer. Nothing a sink has already seen ever needs replaying, and a failure mid-stream is non-retryable by construction.
Beast first. Its parser already decodes chunked framing; switching to buffer_body and pumping the sink is a local change. Teaching SocketHttpClient to decode chunked responses is a separate decision with its own hostile-input bank and fuzz target — that transport is test/reference-only (ADR-0006), and the transfer-encoding refusal is a deliberate security posture, not an oversight.
Spill-to-file (6d) then falls out on the consumer side for free.
Slice 2 — @streaming blob in codegen (6c)
With slice 1 underneath, a @streaming blob output member generates a stream handle rather than a buffered opal::Blob, on client and server. The README limitation narrows to the request side, and docs/generated-types.md grows a real row.
Non-goals
Parsing the stream. ndjson and PGN framing belong to the consumer, exactly as #1527 puts it: "the parsing lives outside it." opal-cpp's job is delivering bytes in bounded pieces.
@streaming blob requests (uploads). Writing one needs chunked request framing, which the http1 codec refuses on purpose. A transport decision, not a codegen one, and not this issue.
Let the consumer hand-write against Beast. What #1527 will do by default. It works, and it means the model stops describing the service — the wire contract lives in C++ in a downstream repo, which is the thing this project exists to avoid.
Reuse the event-stream machinery (ADR-0016). Wrong wire: that is a typed union over WebSocket with an envelope convention. A blob stream is an ordinary HTTP response body with no framing of its own.
Buffer to a temp file inside the transport. Bounds memory without any API change, and trades it for disk plus a latency cliff the caller cannot see. A sink lets the caller choose that.
Open questions
Beast-only for slice 1, or teach the socket client response-side chunked decoding at the same time?
C++ shape of the sink: a push callback (bool(std::string_view) with false to cancel), a pull-based ByteSource, or an async handle in the style of the event-stream API?
Do interceptors see a headers-only response on a streamed call (ReadAfterTransmit taking an Outcome<HttpResponse> whose body is empty), and is that documented or signalled?
Does max_response_bytes apply to a streamed body, or does supplying a sink opt out of it and hand the budget to the caller?
What does a generated operation with a @streaming blob output return, and does the server half land in the same PR as the client half?
Item 6 of #189, option 6c (with 6b underneath it) in
docs/research/client-third-party-api-gaps.md. Filed because a consumer has now hit the fork.Problem
muchq/MoonBase#1527 adds Lichess as a second chess platform, and its export endpoint is a stream:
That issue names the decision it cannot make on its own:
Today the second branch is the only one that works.
@streamingon a blob member is ignored — it generates a fully bufferedopal::Blob(docs/generated-types.md, and the README's Current limitations). ADR-0016 shipped event streams for@streamingunions and deferred this deliberately: "@streamingblobs remain unmodeled […] unbounded blob bodies are a different transport problem."What a Lichess-shaped response meets today, one transport at a time:
SocketHttpClienttransfer-encodingas a request-smuggling desync vector:http: transfer-encoding is not supported(runtime/src/http/http1.cc, pinned inhttp1_hostile_test.cc). Even framed until-EOF instead, the body still arrives as onestd::string.BeastHttpClientmax_response_bytes.So the answer to #1527's open question — "Generated or hand-written client, given the stream?" — is forced rather than chosen, and the first hand-written client in that codebase becomes the precedent for the next one.
#212 (open) added
ClientConfig::max_response_bytes, which bounds a buffered response. That was the missing bound, explicitly not the missing capability: it turns "unbounded memory" into "a failed call", which is the right default and still no way to read a stream.Proposal
Two slices. The first is runtime-only and useful on its own; the second is what a model needs.
Slice 1 — a response body sink on the transport (6b)
A client transport hands body bytes to a caller-supplied sink instead of accumulating them, leaving
HttpResponse::bodyempty.peer_addressandHttpResponse::operation: a field the transport reads and no serializer ever writes.buffer_bodyand pumping the sink is a local change. TeachingSocketHttpClientto decode chunked responses is a separate decision with its own hostile-input bank and fuzz target — that transport is test/reference-only (ADR-0006), and thetransfer-encodingrefusal is a deliberate security posture, not an oversight.Slice 2 —
@streamingblob in codegen (6c)With slice 1 underneath, a
@streamingblob output member generates a stream handle rather than a bufferedopal::Blob, on client and server. The README limitation narrows to the request side, anddocs/generated-types.mdgrows a real row.Non-goals
@streamingblob requests (uploads). Writing one needs chunked request framing, which the http1 codec refuses on purpose. A transport decision, not a codegen one, and not this issue.Alternatives considered
Open questions
bool(std::string_view)with false to cancel), a pull-basedByteSource, or an async handle in the style of the event-stream API?ReadAfterTransmittaking anOutcome<HttpResponse>whose body is empty), and is that documented or signalled?max_response_bytesapply to a streamed body, or does supplying a sink opt out of it and hand the budget to the caller?@streamingblob output return, and does the server half land in the same PR as the client half?