Repository navigation
fix(connect): bound answers apart from requests - #111
Merged
Merged
Conversation
antoinetoussaint-byte
added a commit
that referenced
this pull request
Oct 25, 2026
… is sent, and names itself Review r45 of #111: - f1 (blocker): max_response_bytes defaulted to a fixed 64 MiB, so a service that raised max_message_bytes above it lost answers it used to get. It now defaults to the larger of DEFAULT_MAX_RESPONSE_BYTES and max_message_bytes. - f2 (major): the default drops to 16 MiB, the bound counts the bytes the face sends (the converted document for JSON, not only the proto), and the docstring states the real cost: per call in flight the answer and its encoding, each within the bound; a JSON conversion's transient peak (about 13x the proto for a float list, measured: 3.8 MiB proto, 51 MiB peak) runs on the face's event loop one at a time; the face bounds neither calls in flight nor what the gRPC server holds. - f3: the refusal of an answer past the bound names max_response_bytes and is failed_precondition (400, grpc-status 9), not resource_exhausted (429): the same call is refused the same way when retried. - f4: the docstring is reflowed. Tests: test_a_raised_message_bound_raises_the_answer_bound (100 MiB bound, 70 MiB answer), test_max_response_bytes_counts_the_json_the_face_sends, test_the_default_answer_bound_is_16_mib, and the refusal's code and message on every media type; each fails on 434f403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The face's channel to the service took max_message_bytes as its receive length, so an answer was capped at the request bound: a service that bounds requests at a few hundred KB had a 393 KB embedding answer refused resource_exhausted on Connect while gRPC, relayed as it came, returned it. serve and start take max_response_bytes (64 MiB by default) for the answers the face transcodes; max_message_bytes still bounds requests before they are read and what the face sends the service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is sent, and names itself Review r45 of #111: - f1 (blocker): max_response_bytes defaulted to a fixed 64 MiB, so a service that raised max_message_bytes above it lost answers it used to get. It now defaults to the larger of DEFAULT_MAX_RESPONSE_BYTES and max_message_bytes. - f2 (major): the default drops to 16 MiB, the bound counts the bytes the face sends (the converted document for JSON, not only the proto), and the docstring states the real cost: per call in flight the answer and its encoding, each within the bound; a JSON conversion's transient peak (about 13x the proto for a float list, measured: 3.8 MiB proto, 51 MiB peak) runs on the face's event loop one at a time; the face bounds neither calls in flight nor what the gRPC server holds. - f3: the refusal of an answer past the bound names max_response_bytes and is failed_precondition (400, grpc-status 9), not resource_exhausted (429): the same call is refused the same way when retried. - f4: the docstring is reflowed. Tests: test_a_raised_message_bound_raises_the_answer_bound (100 MiB bound, 70 MiB answer), test_max_response_bytes_counts_the_json_the_face_sends, test_the_default_answer_bound_is_16_mib, and the refusal's code and message on every media type; each fails on 434f403. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…service's refusal for the face's Review r79 of #111: - M1: any "Received message larger than max" text was rewritten to the face's failed_precondition, including a refusal a service passes on from its own gRPC client, so code and reason were false. The face now checks its bounds itself (len of the answer against the codec's bound, before converting) and takes grpcio's text for its own refusal only when it is grpcio's whole wording and names exactly the channel bound the face set. - M2: the JSON check ran after the conversion, which is what costs. JSON answers now have their own bound on the proto, max_json_response_bytes (default the larger of 4 MiB, every answer's bound before, and max_message_bytes), checked before converting. The docstring gives the measured process memory (protobuf 6.33, one 4 MiB answer): floats 18x, nested messages 62x, enum lists 130x, so up to about 520 MiB at the default; a proto answer about 2x beyond the service's own copy (33 MiB for 16 MiB). - m1: the docstring says what is bounded: the proto the service returns, by the codec's bound. - m2: a bound must be a positive number of bytes; 0 or -1 is refused at serve. Tests: test_a_refusal_the_service_passes_on_keeps_its_code_and_words, test_the_json_bound_is_checked_before_converting (the answer past the bound is never converted), test_an_answer_bound_is_a_positive_size, and the JSON bound in the existing bound tests; each fails on the previous head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
antoinetoussaint-byte
force-pushed
the
fix/connect-response-limit
branch
from
October 26, 2026 01:36
9683f99 to
1cadf23
Compare
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
The generated Connect face (
templates/connect/connect_gen.py) opened its channel to the service withgrpc.max_receive_message_length = max_message_bytes. That channel receives the service's answers, so every transcoded answer was capped at the request bound. Found by review r38 of obin-ai/module-model-gateway#210: the gateway bounds requests well below 4 MiB, and a 393 KB embedding answer came back429 resource_exhaustedon Connect while direct gRPC (relayed as it came) returned it.Change
serve(...)/start(...)takemax_response_bytes(defaultDEFAULT_MAX_RESPONSE_BYTES= 64 MiB), the channel's receive length. Past it the call endsresource_exhaustednaming grpcio's reason.max_message_bytesis unchanged: it bounds a request body before it is read (HTTP 413 naming the bound) and is the channel's send length.A bounded large default rather than unbounded: the face buffers a unary answer whole and transcodes it (JSON grows it), so an unbounded default would let one handler's answer size the process's memory. A service that needs more sets
max_response_bytesthe same way it setsmax_message_bytes.Tests (
testdata/connect/test_connect_gen.py, run byTestConnectFaceServesEveryProtocol)content length (N) exceeds the message bound; the existing refusal test now asserts that text too.max_response_bytes=2048set, an answer within it passes and one past it endsresource_exhausted("larger than max") on all five media types.max_receive_message_length = max_message_bytesrestored, the 7 new cases fail and the other 23 pass. With the fix, 30/30 pass.Local:
go build ./...,go vet ./...,go test -run TestConnect ./..., and the Python suite. I did not run the fullgo test ./...or the race job locally (Docker-backed tests); CI covers them.Classification
Fix, at the owner: the face template is what every
connect-endpointservice is synced from. The consumer (module-model-gateway) picks it up on its nextcodefly syncwith this agent's release; no change is needed in its call (serve(..., max_message_bytes=bound)now bounds requests only).Relation to #110
Cut from
main(2c266fd). #110 (concurrency, under independent review) was not touched. Its hunks are in the HTTP/2 relay and the constants after line 63; this change sits in the docstring, one new constant andserve(), so the two should merge without conflict in either order.Reused: grpcio's own channel receive bound (no new mechanism). Not done: no bound on the raw gRPC relay, which keeps the service's and caller's own limits.
Review r45 fixes (commit a7eefee)
max_response_bytesnow defaults to the larger ofDEFAULT_MAX_RESPONSE_BYTESandmax_message_bytes, so a service that raisedmax_message_byteskeeps answers that large. New test: a 100 MiB request bound with a 70 MiB answer returns 200.failed_precondition: HTTP 400 for Connect unary, grpc-status 9 for gRPC-Web, and the error frame for streams.The new tests fail on 434f403. The face suite passes (34 cases); merged with #110 (82a7ade) it merges cleanly and passes 41. The tests sit beside their siblings rather than at the end of the file, which is where they conflicted with #110.
go vetpasses, and so do the Connect Go tests.Review r79 fixes (rebased onto main c43d3c3, commit 1cadf23)
The branch is rebased onto main, which now includes #110, #112 and #113. The original commit is kept, and the r45 fixups are squashed into one commit.
This round supersedes two things in the r45 section above:
The post-conversion JSON size check is gone.
Answers now have two bounds, one per codec:
max_response_bytesfor proto,max_json_response_bytesfor JSON.M1 (major): a service's own refusal was rewritten as the face's. Fixed.
failed_precondition, including a refusal the service passes on from its own gRPC client. Both the code and the reason were then false.test_a_refusal_the_service_passes_on_keeps_its_code_and_words.M2 (major): the memory claim was wrong and the JSON check came too late. Fixed.
JSON answers now have their own bound on the proto the service returns:
max_json_response_bytes, by default the larger of 4 MiB (the bound every answer had before fix(connect): bound answers apart from requests #111) andmax_message_bytes. It is checked before converting.The docstring now gives measured process memory with protobuf 6.33 for one 4 MiB answer (
probe111/amp3.py):That is up to about 520 MiB at the default, the same worst case as before fix(connect): bound answers apart from requests #111.
Proto answers keep
max_response_bytes(16 MiB by default). The face holds about twice the answer beyond the service's own copy: 33 MiB measured for a 16 MiB answer.Test:
test_the_json_bound_is_checked_before_convertingshows an answer past the bound is never converted.m1: the docstring said the bound counts bytes sent. Fixed. It now says what is bounded: the proto the service returns, against the bound for the call's codec.
m2:
max_response_bytes=-1refused every JSON answer. Fixed. Both bounds must be positive numbers of bytes; 0 or -1 is refused atserve. Test:test_an_answer_bound_is_a_positive_size.Behaviour change: a JSON answer above 4 MiB is now refused by default, as it was on main before fix(connect): bound answers apart from requests #111. Proto answers up to 16 MiB still come back.
Checks:
go vetpasses.go test ./...fails onlyTestConnectOnlyServiceSyncs(from fix: a service whose faces are gRPC and Connect alone loads, syncs and starts #113): Buf's BSR rate-limits this machine ("resource_exhausted: too many requests"), so it is unrelated to this diff.🤖 Generated with Claude Code