Skip to content

Commit cf312e5

Browse files
authored
Merge pull request #214 from muchq/claude/kind-fermi-elfabg-213-sink
Stream a response body to a sink instead of buffering it (#213 slice 1)
2 parents ec400dc + eb8dccd commit cf312e5

15 files changed

Lines changed: 1122 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ policy in [docs/versioning.md](docs/versioning.md).
8282

8383
### Added
8484

85+
- **A response body sink: `HttpClient::SendStreaming`** (#213, slice 1 of
86+
`@streaming` blob support). A caller that does not want a response body
87+
buffered passes an `opal::http::BodySink` — an `accept(status, headers)`
88+
asked once per response, and a `write(piece)` given the body in order as it
89+
arrives — and gets back the status and headers with an empty body. It is
90+
what `max_response_bytes` cannot be: that cap bounds what this process
91+
holds, and a download whose size the service does not bound needs the
92+
process not to hold it. `BeastHttpClient` streams for real and never
93+
assembles an accepted body; every other transport inherits a default that
94+
buffers and then hands the body over in one piece, so the contract holds
95+
everywhere and the memory bound follows the transport. Deciding per response
96+
is deliberate: a sink that takes 200s leaves a modeled error's document in
97+
`response.body` where the generated client already reads it. `SendWithRetries`
98+
gains an overload taking a sink, and withholds retryable statuses from it
99+
while retries are enabled — streaming a 503's error document and then
100+
retrying would hand the sink two bodies' worth of bytes with no way to take
101+
the first back. Generated clients do not expose a sink yet; a `@streaming`
102+
blob member still generates a buffered `opal::Blob` (#213 slice 2).
85103
- **A dependency-free Prometheus `/metrics` endpoint** (#91, first work
86104
item). `opal::server::MetricsRegistry` aggregates the existing `Observe`
87105
hooks into the five `http_server_*` families labeled by `service_name`,

docs/production-guide.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,51 @@ to accept 64 MiB.
5353
config.max_response_bytes = std::size_t{4} * 1024 * 1024; // 4 MiB: this API pages
5454
```
5555
56-
Bodies are still buffered, not streamed; a sink-based transport API is the
57-
follow-up if a consumer needs bounded memory for large downloads
58-
(`docs/research/client-third-party-api-gaps.md`, §6).
56+
## Streaming a response body
57+
58+
The cap above bounds a buffered body. A download whose size the service does
59+
not bound needs the other thing: not holding it at all. `SendStreaming` takes
60+
an `opal::http::BodySink` and hands the body over in pieces as it arrives.
61+
62+
```cpp
63+
std::ofstream out("export.pgn", std::ios::binary);
64+
const opal::http::BodySink to_file{
65+
.accept = [](int status, const opal::http::Headers&) { return status == 200; },
66+
.write = [&](std::string_view piece) { return out.write(piece.data(), piece.size()).good(); },
67+
};
68+
auto response = transport->SendStreaming(request, to_file);
69+
```
70+
71+
- **`accept` is asked once per response**, after the status and headers and
72+
before any body byte. True streams the body; false buffers it into
73+
`response.body` exactly as `Send` would. Deciding per response is what lets
74+
a caller take the payload and leave a 404's error document where the rest
75+
of the client already knows to read it.
76+
- **`write` gets each piece in order**, never empty, valid only for the call.
77+
Returning false aborts the transfer: the send fails with a non-retryable
78+
error and the connection is dropped rather than reused.
79+
- **On a streamed response `max_response_bytes` does not apply.** The cap
80+
bounds what this process holds, and a sink holds nothing here. A declined
81+
response is buffered and capped as always.
82+
- **`BeastHttpClient` is the transport that actually streams.** Every other
83+
transport inherits a default that buffers and then hands the body over in
84+
one piece: same delivery, same empty `response.body`, no memory bound. So
85+
the API works everywhere and pays off where it matters.
86+
- **Interceptors see a streamed response without its body** — status and
87+
headers, and nothing under them, because nothing was ever assembled.
88+
89+
With retries enabled, a retryable status (429/5xx) is never offered to the
90+
sink, on any attempt: streaming a 503's error document and then retrying
91+
would leave the sink holding it followed by the real body, with no way to
92+
take the first back. Such a body arrives in `response.body` instead, where
93+
error documents already go. `opal::SendWithRetries` has an overload that
94+
takes a sink and applies this rule.
95+
96+
Generated clients do not expose a sink yet — a `@streaming` blob member still
97+
generates a fully buffered `opal::Blob`. That is
98+
[#213](https://github.com/muchq/opal-cpp/issues/213) slice 2; today a caller
99+
that needs this drives the transport directly, as
100+
`examples/bazel-consumer/response_sink_acceptance_test.cc` does.
59101

60102
## Retries
61103

docs/research/client-third-party-api-gaps.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,14 +191,24 @@ JSON/blob downloads.
191191
**6a** for the initial third-party JSON client. Revisit **6b/6d** when a
192192
concrete multi-MB concurrency budget appears. Do not block 1–4 on **6c**.
193193

194-
**Status (2026-09-09):** 6a, plus the bound it was missing:
195-
`ClientConfig::max_response_bytes` (default 64 MiB) caps what one buffered
196-
response can make the process hold, on both built-in transports. Before it
197-
the Beast client accepted responses of any size. 6b (a transport body sink,
198-
streaming only for non-retryable statuses so the retry loop stays
199-
transparent) is the next slice when a consumer has a blob download to make;
200-
6c waits for a model with a `@streaming` blob, and its upload half needs
201-
chunked transfer-encoding, which the http1 codec refuses by design.
194+
**Status (2026-09-11):** 6a and 6b are in; 6c is
195+
[#213](https://github.com/muchq/opal-cpp/issues/213).
196+
197+
- **6a + the bound it was missing:** `ClientConfig::max_response_bytes`
198+
(default 64 MiB) caps what one buffered response can make the process hold,
199+
on both built-in transports. Before it the Beast client accepted responses
200+
of any size.
201+
- **6b:** `HttpClient::SendStreaming` takes an `opal::http::BodySink` and
202+
hands an accepted body over in pieces; `BeastHttpClient` never holds one,
203+
and every other transport inherits a buffering default that honors the same
204+
contract. A retryable status is never offered to the sink while retries are
205+
enabled, so the retry loop stays transparent. `max_response_bytes` bounds
206+
the buffered path only — the cap is about what this process holds.
207+
- **6c** (a `@streaming` blob behind a generated method) is the remaining
208+
work, driven by
209+
[MoonBase#1527](https://github.com/muchq/MoonBase/issues/1527). Its upload
210+
half needs chunked transfer-encoding, which the http1 codec refuses by
211+
design, and is not part of it.
202212

203213
---
204214

examples/bazel-consumer/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ cc_test(
125125
],
126126
)
127127

128+
# Acceptance for the response body sink (issue #213): a consumer pulling a
129+
# blob payload off a generated server without the body landing in a response
130+
# object, and leaving a modeled error's document where the client looks for it.
131+
cc_test(
132+
name = "response_sink_acceptance_test",
133+
size = "small",
134+
srcs = ["response_sink_acceptance_test.cc"],
135+
copts = OPAL_COPTS,
136+
deps = [
137+
":redirector_client",
138+
":redirector_server",
139+
"@googletest//:gtest_main",
140+
"@opal_cpp//runtime:core",
141+
"@opal_cpp//runtime:http",
142+
"@opal_cpp//runtime:server",
143+
],
144+
)
145+
128146
# Acceptance for ClientConfig::max_response_bytes (issue #189): the
129147
# generated client, built from an endpoint alone, refuses a blob payload
130148
# over the budget without retrying and delivers one at the budget whole.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Out-of-tree acceptance for the response body sink (issue #213): a consumer
2+
// module reaching `opal::http::BodySink` through `@opal_cpp//runtime:http`
3+
// and pulling a blob payload off a generated server without the body landing
4+
// in a response object.
5+
//
6+
// This is the shape a consumer has today, and deliberately so: the sink lives
7+
// on the transport, so a caller drives it directly. Slice 2 of #213 is what
8+
// puts a `@streaming` blob behind a generated method; until then this test is
9+
// the record of what the runtime alone gives you.
10+
//
11+
// The transport here is `SocketHttpClient`, which inherits the default
12+
// `SendStreaming` — it delivers through the sink but buffers on the way, so
13+
// what this test pins is the contract (the sink gets the bytes, the response
14+
// does not) rather than the memory bound. The bound is `BeastHttpClient`'s,
15+
// pinned in the runtime's own beast_client_test.cc, and a consumer gets it by
16+
// injecting that transport instead. Keeping Beast out of this target is what
17+
// lets it run behind a download-blocking proxy alongside the other
18+
// socket-transport tests.
19+
20+
#include <gtest/gtest.h>
21+
22+
#include <cstddef>
23+
#include <memory>
24+
#include <string>
25+
#include <string_view>
26+
27+
#include "acme/redirect/client.h"
28+
#include "acme/redirect/server.h"
29+
#include "opal/core/error.h"
30+
#include "opal/http/message.h"
31+
#include "opal/http/socket_transport.h"
32+
#include "opal/http/transport.h"
33+
34+
namespace {
35+
36+
using acme::redirect::FetchInput;
37+
using acme::redirect::FetchOutput;
38+
using acme::redirect::NoSuchSlug;
39+
using acme::redirect::ProbeInput;
40+
using acme::redirect::ProbeOutput;
41+
using acme::redirect::RedirectorHandler;
42+
using acme::redirect::RedirectorServer;
43+
using acme::redirect::ResolveDynamicInput;
44+
using acme::redirect::ResolveDynamicOutput;
45+
using acme::redirect::ResolveInput;
46+
using acme::redirect::ResolveOutput;
47+
48+
// A payload big enough that nobody would want it in memory twice — the case
49+
// the sink exists for. The content is a repeating pattern rather than one
50+
// character so a test can tell a truncated delivery from a short one.
51+
std::string LargePayload() {
52+
std::string payload;
53+
payload.reserve(512 * 1024);
54+
while (payload.size() < 512 * 1024) {
55+
payload += "the quick brown fox jumps over the lazy dog\n";
56+
}
57+
return payload;
58+
}
59+
60+
class DownloadHandler final : public RedirectorHandler {
61+
public:
62+
explicit DownloadHandler(std::string payload) : payload_(std::move(payload)) {}
63+
64+
opal::Outcome<FetchOutput> Fetch(const FetchInput& input,
65+
const opal::server::RequestContext&) override {
66+
if (input.slug != "big") return NotFound(input.slug);
67+
return FetchOutput{
68+
.status = 200, .etag = "\"big\"", .content = opal::Blob::FromString(payload_)};
69+
}
70+
opal::Outcome<ProbeOutput> Probe(const ProbeInput&,
71+
const opal::server::RequestContext&) override {
72+
return ProbeOutput{.etag = "\"big\"", .content = opal::Blob::FromString(payload_)};
73+
}
74+
opal::Outcome<ResolveOutput> Resolve(const ResolveInput& input,
75+
const opal::server::RequestContext&) override {
76+
return NotFound(input.slug);
77+
}
78+
opal::Outcome<ResolveDynamicOutput> ResolveDynamic(const ResolveDynamicInput& input,
79+
const opal::server::RequestContext&) override {
80+
return NotFound(input.slug);
81+
}
82+
83+
private:
84+
static opal::Error NotFound(const std::string& slug) {
85+
opal::Error error = opal::Error::Modeled("NoSuchSlug", "no slug: " + slug);
86+
error.set_detail(NoSuchSlug{.message = "no slug: " + slug});
87+
return error;
88+
}
89+
90+
std::string payload_;
91+
};
92+
93+
class ResponseSinkAcceptanceTest : public ::testing::Test {
94+
protected:
95+
void SetUp() override { ASSERT_TRUE(transport_.Start(server_.Handler()).ok()); }
96+
void TearDown() override { transport_.Stop(); }
97+
98+
opal::http::HttpRequest Get(const std::string& slug) const {
99+
opal::http::HttpRequest request;
100+
request.method = "GET";
101+
request.target = "/c/" + slug;
102+
return request;
103+
}
104+
105+
std::string payload_ = LargePayload();
106+
RedirectorServer server_{std::make_shared<DownloadHandler>(payload_)};
107+
opal::http::SocketHttpServer transport_;
108+
};
109+
110+
TEST_F(ResponseSinkAcceptanceTest, ABlobPayloadArrivesThroughTheSinkAndNotInTheResponse) {
111+
opal::http::SocketHttpClient client("127.0.0.1", transport_.port());
112+
113+
// What a real consumer does with the pieces: hand them straight to
114+
// something that consumes bytes — a file, a hash, a parser. Nothing here
115+
// keeps the payload, only its length and digest.
116+
std::size_t received = 0;
117+
std::size_t digest = 0;
118+
const opal::http::BodySink to_consumer{
119+
.accept = [](int status, const opal::http::Headers&) { return status == 200; },
120+
.write =
121+
[&](std::string_view piece) {
122+
received += piece.size();
123+
for (const char byte : piece) digest = digest * 31 + static_cast<unsigned char>(byte);
124+
return true;
125+
},
126+
};
127+
128+
const auto response = client.SendStreaming(Get("big"), to_consumer);
129+
ASSERT_TRUE(response.ok()) << response.error().message();
130+
EXPECT_EQ(response->status, 200);
131+
EXPECT_EQ(response->headers.Get("etag").value_or(""), "\"big\"");
132+
EXPECT_EQ(received, payload_.size());
133+
EXPECT_TRUE(response->body.empty()) << "the payload was delivered twice";
134+
135+
std::size_t expected = 0;
136+
for (const char byte : payload_) expected = expected * 31 + static_cast<unsigned char>(byte);
137+
EXPECT_EQ(digest, expected) << "the bytes arrived, but not these bytes";
138+
}
139+
140+
TEST_F(ResponseSinkAcceptanceTest, AModeledErrorIsLeftWhereTheClientLooksForIt) {
141+
// The reason accept() is asked per response: a sink that takes payloads
142+
// must not swallow the error document that the generated client's own
143+
// deserializer needs to turn a 404 into a NoSuchSlug.
144+
opal::http::SocketHttpClient client("127.0.0.1", transport_.port());
145+
146+
bool wrote = false;
147+
const opal::http::BodySink payloads_only{
148+
.accept = [](int status, const opal::http::Headers&) { return status == 200; },
149+
.write =
150+
[&](std::string_view) {
151+
wrote = true;
152+
return true;
153+
},
154+
};
155+
156+
const auto response = client.SendStreaming(Get("missing"), payloads_only);
157+
ASSERT_TRUE(response.ok()) << response.error().message();
158+
EXPECT_EQ(response->status, 404);
159+
EXPECT_FALSE(wrote);
160+
// The modeled member is still in the document, which is what the generated
161+
// client's deserializer reads to build the typed NoSuchSlug detail. Had the
162+
// sink taken this response, it would have read an empty body instead.
163+
EXPECT_NE(response->body.find("no slug: missing"), std::string::npos) << response->body;
164+
}
165+
166+
} // namespace

runtime/BUILD.bazel

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,17 @@ cc_test(
342342
],
343343
)
344344

345+
cc_test(
346+
name = "body_sink_test",
347+
size = "small",
348+
srcs = ["tests/http/body_sink_test.cc"],
349+
copts = COPTS,
350+
deps = [
351+
":http",
352+
"@googletest//:gtest_main",
353+
],
354+
)
355+
345356
cc_test(
346357
name = "http_test",
347358
size = "small",

runtime/include/opal/client/interceptor.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ class Interceptor {
2525
// Runs after each attempt with the request as sent and the transport
2626
// outcome (a response of any status, or a transport error). Observe only:
2727
// logging, metrics, tracing.
28+
//
29+
// On a call whose body went to a BodySink (opal/http/transport.h), the
30+
// response carries its status and headers and an empty body — the bytes
31+
// went to the sink and were never assembled anywhere for a hook to read.
32+
// An interceptor that logs response bodies sees nothing there rather than
33+
// something truncated.
2834
virtual void ReadAfterTransmit(const http::HttpRequest& request,
2935
const Outcome<http::HttpResponse>& outcome, int attempt) {
3036
(void)request;

runtime/include/opal/client/retry.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@ inline Outcome<http::HttpResponse> SendWithRetries(http::HttpClient& transport,
5050
return SendWithRetries(transport, request, policy, {});
5151
}
5252

53+
// The same, with the response body streamed to `sink` rather than buffered
54+
// (issue #213). Retries and streaming disagree about one thing, and this
55+
// settles it: while retries are enabled, a retryable status (429/5xx) is
56+
// buffered into HttpResponse::body and never offered to the sink. Streaming a
57+
// 503's error document and then retrying would leave the sink holding that
58+
// body followed by the real one, with nothing able to take the first back.
59+
//
60+
// The rule is the same on the last attempt as on the first, deliberately: a
61+
// sink takes payloads, never a transient failure's error document, and which
62+
// attempt produced a 503 is not something a caller should have to reason
63+
// about. Nothing is lost either way — such a body is small, and it arrives
64+
// where every other error document already does. With retries disabled
65+
// (max_attempts = 1) no response can be discarded, so the caller's accept()
66+
// stands unaltered for every status.
67+
//
68+
// A failure after the sink has taken bytes is not retryable, so the loop ends
69+
// there whatever the policy says.
70+
Outcome<http::HttpResponse> SendWithRetries(
71+
http::HttpClient& transport, const http::HttpRequest& request, const RetryPolicy& policy,
72+
const std::vector<std::shared_ptr<Interceptor>>& interceptors, const http::BodySink& sink);
73+
5374
} // namespace opal
5475

5576
#endif // OPAL_CLIENT_RETRY_H_

runtime/include/opal/http/beast_transport.h

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,19 @@ class BeastHttpClient : public HttpClient {
276276

277277
Outcome<HttpResponse> Send(const HttpRequest& request) override;
278278

279+
// Streams an accepted response body to the sink and never holds it whole
280+
// (issue #213), which is what max_response_bytes cannot do for a download
281+
// whose size the model does not bound. A declined response is buffered and
282+
// capped exactly as Send() buffers and caps it.
283+
Outcome<HttpResponse> SendStreaming(const HttpRequest& request, const BodySink& sink) override;
284+
279285
private:
280286
struct State; // Hides boost headers from this public header.
281287

282-
// Send()'s body; Send() wraps it so no exception (a bad_alloc from the sync
283-
// drive) crosses the Outcome boundary (ADR-0003).
284-
Outcome<HttpResponse> SendContained(const HttpRequest& request);
288+
// Both public sends' body (`sink` null for Send()); they wrap it so no
289+
// exception (a bad_alloc from the sync drive, or a throwing sink callback)
290+
// crosses the Outcome boundary (ADR-0003).
291+
Outcome<HttpResponse> SendContained(const HttpRequest& request, const BodySink* sink);
285292

286293
std::shared_ptr<State> state_;
287294
};

0 commit comments

Comments
 (0)