Skip to content

Commit 929945b

Browse files
rustyconoverclaude
andcommitted
http: worker-visible response budgets + framework strict-fail enforcement
Phase B of the response-cap rework, building on the operator knobs and conformance scaffolding from 086e3ef. Worker-visible budgets on OutputCollector: * ``out.remaining_response_bytes: int | None`` — wire body bytes the framework will accept this iteration before triggering a continuation token (producer) or strict-fail (unary/exchange). * ``out.remaining_externalized_response_bytes: int | None`` — external channel bytes left this iteration. * ``out.externalization_enabled: bool`` — whether the server has a storage backend wired up. All snapshot semantics — fixed at collector construction, not live. Wire bytes include IPC framing (slightly conservative for a worker computing payload size). Defaults to None/False so non-HTTP transports and existing call sites are unchanged. Framework strict-fail across all HTTP methods, with method-type-aware hardness: * Unary: both caps are *hard*. Overshoot raises ``_RpcHttpError(500)``; ``_set_http_status`` rewrites to 200 + ``X-VGI-RPC-Error`` carrying the EXCEPTION batch. * Stream-exchange: both caps are *hard*. Overshoot replaces the response body with a fresh IPC stream containing the EXCEPTION batch. * Stream-producer: wire cap is *soft* — continuation tokens cover overshoots. External cap stays *hard*: externalised uploads have no escape valve, so a producer pushing more than ``max_externalized_response_bytes`` writes an EXCEPTION batch into the in-progress IPC stream and breaks the loop. A new ``_enforce_response_budgets`` helper in ``_responses.py`` centralises the cap check and the error message format. Cross-language ports must produce error messages containing the tokens ``max_response_bytes`` and ``max_externalized_response_bytes`` — the conformance tests assert on those substrings. Conformance tests (HTTP-only via ``transports=("http",)``): * ``http_response_cap.unary_strict_fail`` * ``http_response_cap.exchange_strict_fail`` * ``http_response_cap.producer_external_strict_fail`` * ``http_response_cap.externalized_strict_fail`` Tests probe the active server's caps via ``http_capabilities()`` (URL stashed on ``LogCollector.http_base_url`` by the CLI runner) and self-skip when caps aren't configured or when the wrong combination of caps + externalisation is in effect. ``LogCollector`` gained the ``http_base_url`` field; ``run_conformance`` gained a ``transports=`` parameter that gates incompatible tests with a clear skip reason. A ``TestHttpResponseCap`` / ``TestHttpResponseCapSoftWire`` pytest class pair in ``_pytest_suite.py`` exercises the same contracts via the strict-cap fixture (``conformance_http_strict_cap_port`` from Phase A). Strict-cap conformance worker bumped from 64 KiB to 1 MiB so the existing conformance suite runs cleanly against it (smaller streams like ``cancellable_producer`` no longer trip the wire cap). Documentation in ``docs/porting-guide.md`` and ``CLAUDE.md`` covers the two-cap design, capability discovery via response headers, the ``transports=`` field, and the strict-fail contract for ports. Verification: * ruff format / ruff check / mypy / ty: clean. * HTTP conformance against regular worker (no caps): 4 strict-fail tests skip cleanly; 97 existing tests pass. * HTTP conformance against strict-cap worker (no externalisation): unary + exchange strict-fail PASS; externalised pair SKIP. * HTTP conformance against strict-cap + externalisation: producer external + externalized PASS; unary + exchange SKIP (wire rescued). * Pipe conformance: 4 strict-fail tests skip via transports filter with clear reason; existing 97 tests pass. * Full pytest: 2986 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 086e3ef commit 929945b

11 files changed

Lines changed: 617 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ The full process before committing code is
8282

8383
- **`http/`** *(optional — `pip install vgi-rpc[http]`)* — HTTP transport package using Falcon (server) and httpx (client). Exposes `make_wsgi_app()` to serve an `RpcServer` as a Falcon WSGI app, `serve_http()` as a convenience wrapper that combines `make_wsgi_app` + automatic free-port selection + `waitress.serve` (prints `PORT:<port>` to stdout for machine-readable discovery), and `http_connect()` for the client side. Streaming is stateless: each exchange carries serialized `StreamState` in a signed token in Arrow custom metadata. Supports pluggable authentication via an `authenticate` callback and `_AuthMiddleware`. Includes `_testing.py` with `make_sync_client()` for in-process testing without a real HTTP server.
8484

85+
**HTTP response caps**. Two independent operator knobs gate response size:
86+
- `max_response_bytes` caps the HTTP body (what literally lands on the wire). Default `None` = unbounded. For producer streams this is *soft* — continuation tokens cover overshoot; for unary and stream-exchange it is *hard* and surfaces as `RpcError` (200 + `X-VGI-RPC-Error: true` + EXCEPTION batch).
87+
- `max_externalized_response_bytes` caps total bytes uploaded to external storage during one HTTP response. Always *hard* — externalised uploads have no escape valve. Strict-fail surfaces the same way.
88+
89+
Externalised payloads are NOT charged against `max_response_bytes` (they leave only tiny pointer batches on the wire). The two knobs answer different operator questions: HTTP body size (proxy/gateway limit) vs per-call data volume.
90+
91+
Both are surfaced via response headers (`VGI-Max-Response-Bytes`, `VGI-Max-Externalized-Response-Bytes`, `VGI-Externalization-Enabled`) so `http_capabilities()` and conformance tests can probe them. Workers can read `out.remaining_response_bytes` / `out.remaining_externalized_response_bytes` / `out.externalization_enabled` on `OutputCollector` to size emits within budget.
92+
93+
The deprecated alias `max_stream_response_bytes` (constructor kwarg, `--max-stream-response-bytes` CLI flag, `VGI_RPC_MAX_STREAM_RESPONSE_BYTES` env) is retained for one release cycle; emits a DeprecationWarning when set. The cap is no longer stream-only — it now governs all HTTP method responses.
94+
8595
### Wire protocol
8696

8797
Multiple IPC streams are written sequentially on the same pipe. Every request batch carries `vgi_rpc.request_version` in custom metadata; the server validates this before dispatch and rejects mismatches with `VersionError`. Each method call writes one request stream and reads one response stream:

docs/porting-guide.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,105 @@ In order, smallest-blast-radius first:
8282
- **Java**: aligned for the stdio transport (120 access-log entries validate). HTTP transport does not yet wire the dispatch hook; stream_id stability across HTTP continuations is a follow-up — see `vgi-rpc-go` for the reference state-token plumbing.
8383
- **Rust**: aligned for the stdio transport (120 access-log entries validate). HTTP transport does not yet wire the new DispatchInfo fields (remote_addr, request_data, stable stream_id via state-token round-trip) — see `vgi-rpc-go` for reference plumbing.
8484

85+
## HTTP response-cap conformance
86+
87+
The conformance suite includes HTTP-only tests under category
88+
`http_response_cap.*` that verify the framework refuses to emit
89+
oversize responses when the operator has configured caps and there is
90+
no externalisation escape valve.
91+
92+
### Two operator knobs
93+
94+
- **`max_response_bytes`** — caps the HTTP body size (the bytes that
95+
literally land on the wire). Externalised payloads do not count
96+
against this; their pointer batches are tiny.
97+
- **`max_externalized_response_bytes`** — caps the total bytes
98+
uploaded to external storage during one HTTP response. Bounds how
99+
much data the client will end up fetching for one RPC, regardless
100+
of how the framework chose to deliver it.
101+
102+
Both default to `None` (unbounded) and are configurable via
103+
constructor kwargs, CLI flags, or env vars (see `make_wsgi_app`,
104+
`serve_http`, and `run_server` for the full set; the deprecated
105+
`max_stream_response_bytes` alias remains for one release cycle).
106+
107+
### Capability discovery (HTTP response headers)
108+
109+
Servers advertise the configured caps so capability-aware clients and
110+
conformance tests can probe without a separate handshake:
111+
112+
| Header | Value when set |
113+
|---|---|
114+
| `VGI-Max-Response-Bytes` | integer body cap |
115+
| `VGI-Max-Externalized-Response-Bytes` | integer external cap |
116+
| `VGI-Externalization-Enabled` | `true` or `false` (always present) |
117+
118+
Cross-language ports must emit these headers on every response when
119+
the corresponding knob is configured.
120+
121+
### Strict-fail behaviour by method type
122+
123+
| Method type | Wire cap (`max_response_bytes`) | External cap |
124+
|---|---|---|
125+
| Unary | hard — strict-fail | hard — strict-fail |
126+
| Stream-exchange | hard — strict-fail | hard — strict-fail |
127+
| Stream-producer | **soft** — continuation tokens cover overshoot | hard — strict-fail |
128+
129+
Strict-fail surfaces as the existing 200 + EXCEPTION-batch shape
130+
(`_set_http_status` rewrites 500 → 200 with `X-VGI-RPC-Error: true`
131+
for unary; producer/exchange append a zero-row EXCEPTION batch to the
132+
in-progress IPC stream). The client sees a normal `RpcError`.
133+
134+
The error message is one of:
135+
136+
- `HTTP body exceeds max_response_bytes (...) for method '<name>'`
137+
- `Externalised payload exceeds max_externalized_response_bytes (...) for method '<name>'`
138+
139+
Cross-language ports must produce error messages containing the
140+
token `max_response_bytes` or `max_externalized_response_bytes`
141+
respectively — the conformance tests assert on those substrings.
142+
143+
### `transports` field on conformance tests
144+
145+
The Python catalog gained a `transports: tuple[Literal["pipe","http","unix"], ...]`
146+
field on `@_conformance_test`, defaulting to all three. The CLI
147+
runner (`vgi-rpc-test`) detects the active transport from the user's
148+
flag (`--cmd``pipe`, `--unix``unix`, `--url``http`) and
149+
skips tests whose `transports` tuple excludes it. Ports must
150+
honour this for the four `http_response_cap.*` tests:
151+
152+
- `http_response_cap.unary_strict_fail` (HTTP only)
153+
- `http_response_cap.exchange_strict_fail` (HTTP only)
154+
- `http_response_cap.producer_external_strict_fail` (HTTP only;
155+
also requires externalisation enabled and an external cap)
156+
- `http_response_cap.externalized_strict_fail` (HTTP only; same
157+
preconditions)
158+
159+
The tests self-skip when caps aren't configured, so a port can run
160+
the full suite against any worker without these failing. To
161+
exercise them, boot a strict-cap worker — see
162+
`tests/serve_conformance_http_strict.py` for the Python reference
163+
(defaults to 1 MiB body + 1 MiB external).
164+
165+
### Worker visibility (optional)
166+
167+
`OutputCollector` exposes three new properties so worker code can
168+
size its emit to the available budget:
169+
170+
- `out.remaining_response_bytes: int | None` — wire body bytes left
171+
this iteration.
172+
- `out.remaining_externalized_response_bytes: int | None` — external
173+
channel bytes left.
174+
- `out.externalization_enabled: bool` — whether the server has a
175+
storage backend wired up.
176+
177+
Snapshot semantic: each value is fixed at collector construction;
178+
within one `state.process()` call it does not update as the worker
179+
emits. Wire bytes include IPC framing (slightly conservative for a
180+
worker computing payload size). Optional surface; ports that don't
181+
expose it are still conformant — strict-fail catches workers that
182+
ignore the budget.
183+
85184
## Gotchas
86185

87186
- **Arrow dictionary encoding.** Across language Arrow libraries, the placement of dictionary messages in IPC streams differs. The schema's `request_data` round-trip rule was chosen specifically to absorb this — don't try to byte-match Python.

tests/serve_conformance_http_strict.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,19 @@ def main() -> None:
3939
parser.add_argument(
4040
"--max-response-bytes",
4141
type=int,
42-
default=64 * 1024,
43-
help="HTTP body cap (default: 64 KiB).",
42+
# 1 MiB is large enough that incidental tests (e.g.
43+
# ``cancellable_producer`` running for ~1s before being cancelled)
44+
# don't trip the cap, while still being small enough that the
45+
# ``http_response_cap.*`` tests' 4x target overshoots provably
46+
# (4 MiB > 1 MiB).
47+
default=1024 * 1024,
48+
help="HTTP body cap (default: 1 MiB).",
4449
)
4550
parser.add_argument(
4651
"--max-externalized-response-bytes",
4752
type=int,
48-
default=64 * 1024,
49-
help="External-channel cap per HTTP response (default: 64 KiB).",
53+
default=1024 * 1024,
54+
help="External-channel cap per HTTP response (default: 1 MiB).",
5055
)
5156
parser.add_argument(
5257
"--fake-storage",

tests/test_conformance_runner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ def test_full_suite_all_pass(self) -> None:
3333
suite = run_conformance(proxy, log_collector)
3434
assert suite.success, f"Failed tests: {[r.name for r in suite.results if not r.passed]}"
3535
assert suite.total > 0
36-
assert suite.passed == suite.total
36+
# When ``transport`` is not specified, ``run_conformance`` runs every
37+
# registered test regardless of the per-test ``transports`` filter.
38+
# HTTP-only tests (``http_response_cap.*``) self-skip via
39+
# ``_ConformanceSkip`` because the LogCollector has no
40+
# ``http_base_url``; they appear as skipped, not failed.
41+
assert suite.passed + suite.skipped == suite.total
3742
assert suite.failed == 0
3843

3944
def test_filter_mechanism(self) -> None:

vgi_rpc/conformance/_pytest_suite.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,3 +1767,84 @@ def test_different_seed(self, conformance_conn: ConnFactory) -> None:
17671767
with session:
17681768
out = session.exchange(AnnotatedBatch.from_pydict({"value": [7.0]}))
17691769
assert out.batch.column("value")[0].as_py() == pytest.approx(7.0)
1770+
1771+
1772+
# ---------------------------------------------------------------------------
1773+
# HTTP response cap (strict-fail) tests — HTTP-only
1774+
# ---------------------------------------------------------------------------
1775+
#
1776+
# These tests verify the framework's strict-fail behaviour for HTTP
1777+
# responses that overshoot the operator-configured caps. They require a
1778+
# server booted with ``max_response_bytes`` (and optionally
1779+
# ``max_externalized_response_bytes``) set, so they bypass the
1780+
# ``conformance_conn`` matrix and use the strict-cap fixture directly.
1781+
1782+
1783+
class TestHttpResponseCap:
1784+
"""HTTP-only strict-fail tests for response-size caps.
1785+
1786+
Mirror the catalog-based tests in ``_runner.py``'s ``http_response_cap``
1787+
category. Use ``conformance_http_strict_cap_port`` (a session-scoped
1788+
fixture booting a worker with tight caps) so the overshoot is provably
1789+
triggered.
1790+
"""
1791+
1792+
def _connect(self, port: int) -> Any:
1793+
"""Open an HTTP connection to the strict-cap conformance worker."""
1794+
from vgi_rpc.http import http_connect
1795+
1796+
return http_connect(ConformanceService, f"http://127.0.0.1:{port}")
1797+
1798+
def test_unary_strict_fail(self, conformance_http_strict_cap_port: int) -> None:
1799+
"""Unary returning more bytes than ``max_response_bytes`` allows surfaces RpcError."""
1800+
from vgi_rpc.http import http_capabilities
1801+
1802+
caps = http_capabilities(base_url=f"http://127.0.0.1:{conformance_http_strict_cap_port}")
1803+
assert caps.max_response_bytes is not None, "strict-cap fixture must advertise a wire cap"
1804+
with (
1805+
self._connect(conformance_http_strict_cap_port) as proxy,
1806+
pytest.raises(RpcError, match=r"max_response_bytes"),
1807+
):
1808+
proxy.oversized_unary(target_bytes=caps.max_response_bytes * 4)
1809+
1810+
def test_exchange_strict_fail(self, conformance_http_strict_cap_port: int) -> None:
1811+
"""Exchange returning oversize output surfaces RpcError."""
1812+
from vgi_rpc.http import http_capabilities
1813+
1814+
caps = http_capabilities(base_url=f"http://127.0.0.1:{conformance_http_strict_cap_port}")
1815+
assert caps.max_response_bytes is not None
1816+
target_rows = max(1024, (caps.max_response_bytes * 4) // 16)
1817+
with (
1818+
self._connect(conformance_http_strict_cap_port) as proxy,
1819+
pytest.raises(RpcError, match=r"max_response_bytes"),
1820+
proxy.exchange_oversized(rows_per_batch=target_rows) as session,
1821+
):
1822+
session.exchange(AnnotatedBatch.from_pydict({"value": [1.0]}))
1823+
1824+
1825+
class TestHttpResponseCapSoftWire:
1826+
"""Producer streams have a *soft* wire cap.
1827+
1828+
Verifies the design choice that a producer emitting more than
1829+
``max_response_bytes`` does **not** strict-fail when there's no
1830+
externalisation — continuation tokens cover the overshoot
1831+
transparently. The opposite direction (strict-fail) is exercised
1832+
in :class:`TestHttpResponseCap` for unary and exchange.
1833+
"""
1834+
1835+
def test_producer_overshoot_uses_continuation(self, conformance_http_strict_cap_port: int) -> None:
1836+
"""Oversize producer emit splits across continuation tokens, not RpcError."""
1837+
from vgi_rpc.http import http_capabilities, http_connect
1838+
1839+
caps = http_capabilities(base_url=f"http://127.0.0.1:{conformance_http_strict_cap_port}")
1840+
assert caps.max_response_bytes is not None
1841+
# Emit ~2x the wire cap of int64+int64 rows in a single batch.
1842+
# A single oversized iteration overflows the cap; the framework
1843+
# emits the body and mints a continuation token. No RpcError.
1844+
target_rows = max(1024, (caps.max_response_bytes * 2) // 16)
1845+
with http_connect(ConformanceService, f"http://127.0.0.1:{conformance_http_strict_cap_port}") as proxy:
1846+
batches = list(proxy.produce_oversized_batch(rows_per_batch=target_rows))
1847+
# Single emit + finish; the framework may have split it into
1848+
# the data batch + a continuation token + trailing finish, but
1849+
# the client-visible batches are: 1 data batch.
1850+
assert sum(b.batch.num_rows for b in batches) == target_rows

0 commit comments

Comments
 (0)