1919
2020Request bodies are bounded by ``max_message_bytes`` (gRPC's 4 MiB unless the service
2121raises it) before they are read. An answer the face transcodes is bounded apart, by
22- ``max_response_bytes`` (64 MiB unless the service sets it), so a service that keeps its
23- requests small still answers with what its handler returns; past it the call ends
24- ``resource_exhausted``. Both are keyword options to ``serve`` and ``start``; a relayed gRPC
25- call keeps the service's and the caller's own bounds. Every read is bounded in time: an
26- HTTP/1.1 read by
27- ``read_timeout`` (``idle_timeout`` between requests), and a relayed gRPC connection is
28- closed once it has had no open call for ``idle_timeout``, as Go's HTTP/2 server bounds an
29- idle connection. A call in flight is never cut by the face, however long its handler
30- takes to answer: its deadline and the gRPC server's own policy bound it.
22+ ``max_response_bytes``: by default the larger of ``DEFAULT_MAX_RESPONSE_BYTES`` (16 MiB)
23+ and ``max_message_bytes``, so a service that keeps its requests small still answers
24+ with what its handler returns, and one that raised its message bound keeps answers that
25+ large. The bound counts the bytes the face sends: the answer as the service encoded it
26+ for proto, the converted document for JSON. Past it the call ends ``failed_precondition``
27+ naming ``max_response_bytes``: the answer is too large for this face's configuration, and
28+ asking again does not change it. Within the bound, Connect and gRPC-Web return what the
29+ gRPC server returns. Both bounds are keyword options to ``serve`` and ``start``; a relayed
30+ gRPC call keeps the service's and the caller's own bounds.
31+
32+ What a transcoded answer costs the face: while a call is in flight it holds the answer as
33+ received and its encoding, each at most ``max_response_bytes``, until the client reads
34+ it. Converting to JSON holds more while it runs, about 13 times the proto size for a list
35+ of floats (protobuf 6.33), so about 210 MiB at the default bound; conversions run on the
36+ face's event loop, one at a time, so that peak does not add up across calls. A proto
37+ answer is not converted. The face does not bound how many calls are in flight, as the
38+ gRPC server does not.
39+
40+ Every read is bounded in time: an HTTP/1.1 read by ``read_timeout`` (``idle_timeout``
41+ between requests), and a relayed gRPC connection is closed once it has had no open call
42+ for ``idle_timeout``, as Go's HTTP/2 server bounds an idle connection. A call in flight
43+ is never cut by the face, however long its handler takes to answer: its deadline and the
44+ gRPC server's own policy bound it.
3145A JSON request field the service's proto does not know is ignored, as connect-go's codec
3246ignores it, so a client built from a newer proto calls a Python service and a Go one alike.
3347
6276# The port a deployment hands the face (CODEFLY_CONNECT_PORT); ``start`` reads only the variable.
6377DEFAULT_PORT = 8081
6478DEFAULT_MAX_MESSAGE_BYTES = 4 * 1024 * 1024
65- DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
79+ DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
6680DEFAULT_READ_TIMEOUT_S = 30.0
6781DEFAULT_IDLE_TIMEOUT_S = 120.0
6882
102116)
103117_TRANSPORT_PREFIXES = ("connect-" , "grpc-" , "x-grpc-web" , ":" )
104118_GRPC_TIMEOUT = re .compile (r"^(\d{1,8})([HMSmun])$" )
119+ # grpcio's refusal of an answer past the face's own receive bound (its channel is the client).
120+ _ANSWER_PAST_BOUND = re .compile (r"CLIENT: Received message larger than max \((\d+) vs\. (\d+)\)" )
105121_GRPC_UNITS = {"H" : 3600.0 , "M" : 60.0 , "S" : 1.0 , "m" : 1e-3 , "u" : 1e-6 , "n" : 1e-9 }
106122_CODES = {
107123 grpc .StatusCode .CANCELLED : ("canceled" , 499 ),
@@ -135,12 +151,18 @@ def __init__(self, code: grpc.StatusCode, message: str, header=(), trailer=()):
135151
136152 @classmethod
137153 def of (cls , error : grpc .aio .AioRpcError ) -> CallError :
138- return cls (
139- error .code (),
140- error .details () or "" ,
141- error .initial_metadata () or (),
142- error .trailing_metadata () or (),
143- )
154+ code , details = error .code (), error .details () or ""
155+ past = _ANSWER_PAST_BOUND .search (details )
156+ if code is grpc .StatusCode .RESOURCE_EXHAUSTED and past :
157+ code , details = _answer_past_bound (int (past .group (1 )), int (past .group (2 )))
158+ return cls (code , details , error .initial_metadata () or (), error .trailing_metadata () or ())
159+
160+
161+ def _answer_past_bound (size : int , bound : int ) -> tuple [grpc .StatusCode , str ]:
162+ return (
163+ grpc .StatusCode .FAILED_PRECONDITION ,
164+ f"the answer ({ size } bytes) exceeds the Connect face's max_response_bytes ({ bound } )" ,
165+ )
144166
145167
146168class _Plain (Exception ):
@@ -174,13 +196,15 @@ async def serve(
174196 host : str = "0.0.0.0" ,
175197 grpc_host : str = "127.0.0.1" ,
176198 max_message_bytes : int = DEFAULT_MAX_MESSAGE_BYTES ,
177- max_response_bytes : int = DEFAULT_MAX_RESPONSE_BYTES ,
199+ max_response_bytes : int | None = None ,
178200 read_timeout : float = DEFAULT_READ_TIMEOUT_S ,
179201 idle_timeout : float = DEFAULT_IDLE_TIMEOUT_S ,
180202) -> ConnectFace :
181203 """Start the Connect face on ``host``:``port`` (0 for an ephemeral one) over the
182204 grpc.aio server listening on ``grpc_host``:``grpc_port``, and return it started.
183205 Call it on the loop that serves the gRPC server, once that server listens."""
206+ if max_response_bytes is None :
207+ max_response_bytes = max (DEFAULT_MAX_RESPONSE_BYTES , max_message_bytes )
184208 target = f"{ grpc_host } :{ grpc_port } "
185209 channel = grpc .aio .insecure_channel (
186210 target ,
@@ -191,7 +215,12 @@ async def serve(
191215 ],
192216 )
193217 connection = _Connection (
194- channel , (grpc_host , grpc_port ), max_message_bytes , read_timeout , idle_timeout
218+ channel ,
219+ (grpc_host , grpc_port ),
220+ max_message_bytes ,
221+ max_response_bytes ,
222+ read_timeout ,
223+ idle_timeout ,
195224 )
196225 face : ConnectFace
197226
@@ -284,8 +313,9 @@ def _emit(self):
284313
285314
286315class _Connection :
287- def __init__ (self , channel , grpc_address , limit , read_timeout , idle_timeout ):
316+ def __init__ (self , channel , grpc_address , limit , answer_limit , read_timeout , idle_timeout ):
288317 self .channel , self .grpc_address , self .limit = channel , grpc_address , limit
318+ self .answer_limit = answer_limit
289319 self .read_timeout , self .idle_timeout = read_timeout , idle_timeout
290320
291321 async def serve (self , reader , writer ):
@@ -466,7 +496,16 @@ async def _dispatch(self, exchange, request, body):
466496 codec = protocol .codec
467497 if codec is None :
468498 raise _Plain (415 , f"unsupported codec in { content_type !r} " )
469- call = _Call (self .channel , path , method , codec , request .headers , headers , self .limit )
499+ call = _Call (
500+ self .channel ,
501+ path ,
502+ method ,
503+ codec ,
504+ request .headers ,
505+ headers ,
506+ self .limit ,
507+ self .answer_limit ,
508+ )
470509 await protocol .answer (exchange , call , body , headers )
471510
472511
@@ -496,11 +535,15 @@ def request(self, cls, data: bytes) -> bytes:
496535 except (json_format .ParseError , UnicodeDecodeError , ValueError ) as exc :
497536 raise CallError (grpc .StatusCode .INVALID_ARGUMENT , f"unmarshal JSON: { exc } " ) from exc
498537
499- def response (self , cls , data : bytes ) -> bytes :
538+ def response (self , cls , data : bytes , limit : int ) -> bytes :
539+ """The answer in this codec; the proto as received is within ``limit`` already."""
500540 if self .name == "proto" :
501541 return data
502542 document = json_format .MessageToDict (cls .FromString (data ))
503- return json .dumps (document , separators = ("," , ":" )).encode ()
543+ encoded = json .dumps (document , separators = ("," , ":" )).encode ()
544+ if len (encoded ) > limit :
545+ raise CallError (* _answer_past_bound (len (encoded ), limit ))
546+ return encoded
504547
505548
506549def _codec (name ):
@@ -511,9 +554,9 @@ class _Call:
511554 """One call dispatched through the gRPC server: request bytes in the protocol's
512555 codec, answers out in it."""
513556
514- def __init__ (self , channel , path , method , codec , raw_headers , headers , limit ):
557+ def __init__ (self , channel , path , method , codec , raw_headers , headers , limit , answer_limit ):
515558 self .channel , self .path , self .method , self .codec = channel , path , method , codec
516- self .limit = limit
559+ self .limit , self . answer_limit = limit , answer_limit
517560 self .input = message_factory .GetMessageClass (method .input_type )
518561 self .output = message_factory .GetMessageClass (method .output_type )
519562 self .metadata = _metadata (raw_headers )
@@ -555,7 +598,7 @@ async def unary(self, message: bytes):
555598 try :
556599 answer = await call
557600 return (
558- self .codec .response (self .output , answer ),
601+ self .codec .response (self .output , answer , self . answer_limit ),
559602 tuple (await call .initial_metadata () or ()),
560603 tuple (await call .trailing_metadata () or ()),
561604 )
@@ -720,11 +763,13 @@ async def _relay(exchange, call, stream):
720763 return stream .trailer
721764 try :
722765 async for answer in stream :
723- data = call .codec .response (call .output , answer )
766+ data = call .codec .response (call .output , answer , call . answer_limit )
724767 await exchange .send (h11 .Data (data = _ENVELOPE .pack (0 , len (data )) + data ))
725768 return tuple (await stream .trailing_metadata () or ())
726769 except grpc .aio .AioRpcError as exc :
727770 return CallError .of (exc )
771+ except CallError as error :
772+ return error
728773 finally :
729774 stream .cancel ()
730775
0 commit comments