Coverage for gco/services/request_size_middleware.py: 82.43%
52 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Shared request-body size enforcement for GCO FastAPI services."""
3from __future__ import annotations
5from collections import deque
7from starlette.responses import JSONResponse
8from starlette.types import ASGIApp, Message, Receive, Scope, Send
10DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576
13class RequestSizeLimitMiddleware:
14 """Reject request bodies larger than the configured byte limit.
16 ``Content-Length`` is only an early-rejection optimization. Every accepted
17 request is read from the ASGI receive channel and counted before it reaches
18 authentication or a route handler, so a missing, malformed, negative, or
19 deliberately under-reported header cannot bypass the limit. Buffered
20 messages are replayed unchanged to preserve the exact bytes used by HMAC
21 validation and downstream parsing.
22 """
24 def __init__(self, app: ASGIApp, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES) -> None:
25 if max_body_bytes < 0: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 raise ValueError("max_body_bytes must be non-negative")
27 self.app = app
28 self.max_body_bytes = max_body_bytes
30 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
31 if scope["type"] != "http":
32 await self.app(scope, receive, send)
33 return
35 if self._declared_size_exceeds_limit(scope):
36 await self._too_large_response()(scope, receive, send)
37 return
39 buffered_messages: deque[Message] = deque()
40 received_bytes = 0
41 while True:
42 message = await receive()
43 message_type = message["type"]
45 if message_type == "http.request": 45 ↛ 54line 45 didn't jump to line 54 because the condition on line 45 was always true
46 received_bytes += len(message.get("body", b""))
47 if received_bytes > self.max_body_bytes:
48 await self._too_large_response()(scope, receive, send)
49 return
50 buffered_messages.append(message)
51 if not message.get("more_body", False): 51 ↛ 41line 51 didn't jump to line 41 because the condition on line 51 was always true
52 break
53 else:
54 buffered_messages.append(message)
55 if message_type == "http.disconnect":
56 break
58 async def replay_receive() -> Message:
59 if buffered_messages: 59 ↛ 61line 59 didn't jump to line 61 because the condition on line 59 was always true
60 return buffered_messages.popleft()
61 return await receive()
63 await self.app(scope, replay_receive, send)
65 def _declared_size_exceeds_limit(self, scope: Scope) -> bool:
66 """Return true if any valid Content-Length value is already too large."""
67 for name, raw_value in scope.get("headers", []):
68 if name.lower() != b"content-length":
69 continue
70 try:
71 declared_size = int(raw_value.decode("ascii"))
72 except UnicodeDecodeError, ValueError:
73 continue
74 if declared_size > self.max_body_bytes:
75 return True
76 return False
78 def _too_large_response(self) -> JSONResponse:
79 return JSONResponse(
80 status_code=413,
81 content={"detail": f"Request body exceeds maximum size of {self.max_body_bytes} bytes"},
82 )