Skip to content

Commit 8cb2b77

Browse files
authored
feat: Add HTTP request body size limit to OpenAI frontend (#8787)
1 parent 9082a24 commit 8cb2b77

7 files changed

Lines changed: 399 additions & 8 deletions

File tree

python/openai/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,3 +949,28 @@ curl -H "api-key: my-secret-key" \
949949
# Multiple APIs in single argument with shared authentication
950950
--openai-restricted-api "inference,model-repository shared-key shared-secret"
951951
```
952+
953+
## HTTP Request Body Size Limit
954+
955+
The frontend enforces a maximum request body size prior to JSON parsing. Requests that exceed this limit are rejected with an error response.
956+
957+
Use `--http-max-input-size` to configure the limit (default: `67108864` bytes / 64 MiB):
958+
959+
```bash
960+
python3 openai_frontend/main.py \
961+
--model-repository /path/to/models \
962+
--tokenizer meta-llama/Meta-Llama-3.1-8B-Instruct \
963+
--http-max-input-size 67108864
964+
```
965+
966+
The limit applies to all endpoints. Example error response:
967+
968+
```json
969+
{
970+
"error": {
971+
"message": "Request content size exceeds the maximum allowed input size of 67108864 bytes. Use --http-max-input-size to increase the limit.",
972+
"type": "invalid_request_error",
973+
"code": "content_too_large"
974+
}
975+
}
976+
```
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Copyright 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Redistribution and use in source and binary forms, with or without
4+
# modification, are permitted provided that the following conditions
5+
# are met:
6+
# * Redistributions of source code must retain the above copyright
7+
# notice, this list of conditions and the following disclaimer.
8+
# * Redistributions in binary form must reproduce the above copyright
9+
# notice, this list of conditions and the following disclaimer in the
10+
# documentation and/or other materials provided with the distribution.
11+
# * Neither the name of NVIDIA CORPORATION nor the names of its
12+
# contributors may be used to endorse or promote products derived
13+
# from this software without specific prior written permission.
14+
#
15+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY
16+
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
18+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
19+
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
20+
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
21+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
22+
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
23+
# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26+
27+
from fastapi.responses import JSONResponse
28+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
29+
from utils.utils import StatusCode, validate_positive_int
30+
31+
32+
async def _disconnect_receive() -> Message:
33+
return {"type": "http.disconnect"}
34+
35+
36+
class RequestSizeLimitMiddleware:
37+
"""
38+
Reject HTTP requests whose body exceeds ``http_max_input_size`` bytes.
39+
First validation rejects on the Content-Length header before any body bytes are
40+
read. Second validation streams the body chunks, counting bytes as they arrive,
41+
and rejects as soon as the running total crosses the limit. Driving
42+
receive() from the middleware protects every endpoint, including
43+
handlers that never read the body. The buffered body is released the
44+
moment the application consumes it, so the middleware contributes no
45+
sustained memory overhead.
46+
"""
47+
48+
def __init__(self, app: ASGIApp, http_max_input_size: int) -> None:
49+
self.app = app
50+
self.http_max_input_size = validate_positive_int(http_max_input_size)
51+
52+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
53+
if scope["type"] != "http":
54+
await self.app(scope, receive, send)
55+
return
56+
57+
# Stage 1: reject on Content-Length before reading any body bytes.
58+
for name, value in scope["headers"]:
59+
if name != b"content-length":
60+
continue
61+
try:
62+
content_length = int(value)
63+
except ValueError:
64+
await self._send_error(
65+
scope,
66+
send,
67+
StatusCode.CLIENT_ERROR,
68+
"invalid_content_length",
69+
"Invalid Content-Length header: not an integer.",
70+
)
71+
return
72+
if content_length < 0:
73+
await self._send_error(
74+
scope,
75+
send,
76+
StatusCode.CLIENT_ERROR,
77+
"invalid_content_length",
78+
"Invalid Content-Length header: must be non-negative.",
79+
)
80+
return
81+
if content_length > self.http_max_input_size:
82+
await self._send_error(
83+
scope,
84+
send,
85+
StatusCode.CONTENT_TOO_LARGE,
86+
"content_too_large",
87+
self._oversized_request_message(
88+
content_length, self.http_max_input_size
89+
),
90+
)
91+
return
92+
break
93+
94+
# Stage 2: count chunks as they arrive, reject if total exceeds limit.
95+
body_chunks: list[bytes] = []
96+
total = 0
97+
while True:
98+
message = await receive()
99+
if message["type"] != "http.request":
100+
return
101+
chunk = message.get("body", b"")
102+
total += len(chunk)
103+
if total > self.http_max_input_size:
104+
await self._send_error(
105+
scope,
106+
send,
107+
StatusCode.CONTENT_TOO_LARGE,
108+
"content_too_large",
109+
self._oversized_request_message(total, self.http_max_input_size),
110+
)
111+
return
112+
body_chunks.append(chunk)
113+
if not message.get("more_body", False):
114+
break
115+
116+
# Assemble the buffered body and replay it to the app.
117+
body_message: Message = {
118+
"type": "http.request",
119+
"body": b"".join(body_chunks),
120+
"more_body": False,
121+
}
122+
del body_chunks
123+
124+
async def replay_receive() -> Message:
125+
nonlocal body_message
126+
if body_message is not None:
127+
# Drop the reference on hand-off so the body is freed while
128+
# the app processes it, instead of being held by this closure.
129+
message, body_message = body_message, None
130+
return message
131+
# Body already delivered — delegate to the original receive() so
132+
# streaming responses can wait for the real client disconnect.
133+
return await receive()
134+
135+
await self.app(scope, replay_receive, send)
136+
137+
@staticmethod
138+
def _oversized_request_message(actual_bytes: int, max_bytes: int) -> str:
139+
return (
140+
f"Request size of {actual_bytes} bytes exceeds the maximum allowed "
141+
f"input size of {max_bytes} bytes. "
142+
f"Use --http-max-input-size to increase the limit."
143+
)
144+
145+
async def _send_error(
146+
self,
147+
scope: Scope,
148+
send: Send,
149+
status_code: int,
150+
code: str,
151+
message: str,
152+
) -> None:
153+
response = JSONResponse(
154+
status_code=status_code,
155+
content={
156+
"error": {
157+
"message": message,
158+
"type": "invalid_request_error",
159+
"code": code,
160+
}
161+
},
162+
)
163+
await response(scope, _disconnect_receive, send)

python/openai/openai_frontend/frontend/fastapi_frontend.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
APIRestrictionMiddleware,
3535
RestrictedFeatures,
3636
)
37+
from frontend.fastapi.middleware.request_size import RequestSizeLimitMiddleware
3738
from frontend.fastapi.routers import (
3839
chat,
3940
completions,
@@ -43,6 +44,7 @@
4344
observability,
4445
)
4546
from frontend.frontend import OpenAIFrontend
47+
from utils.utils import HTTP_DEFAULT_MAX_INPUT_SIZE
4648

4749

4850
class FastApiFrontend(OpenAIFrontend):
@@ -53,10 +55,12 @@ def __init__(
5355
port: int = 8000,
5456
log_level: str = "info",
5557
restricted_apis: list = None,
58+
http_max_input_size: int = HTTP_DEFAULT_MAX_INPUT_SIZE,
5659
):
5760
self.host: str = host
5861
self.port: int = port
5962
self.log_level: str = log_level
63+
self.http_max_input_size: int = http_max_input_size
6064
if restricted_apis:
6165
self.restricted_apis: RestrictedFeatures = RestrictedFeatures(
6266
restricted_apis
@@ -111,6 +115,7 @@ def _create_app(self):
111115
self._add_cors_middleware(app)
112116
if self.restricted_apis != None:
113117
self._add_api_restriction_middleware(app)
118+
self._add_request_size_limit_middleware(app)
114119

115120
return app
116121

@@ -137,3 +142,10 @@ def _add_api_restriction_middleware(self, app: FastAPI):
137142
print(
138143
f"[INFO] API restrictions enabled. Restricted API endpoints: {self.restricted_apis.RestrictionDict()}"
139144
)
145+
146+
def _add_request_size_limit_middleware(self, app: FastAPI):
147+
app.add_middleware(
148+
RequestSizeLimitMiddleware,
149+
http_max_input_size=self.http_max_input_size,
150+
)
151+
print(f"[INFO] HTTP request size limit set to {self.http_max_input_size} bytes")

python/openai/openai_frontend/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import tritonserver
3535
from engine.triton_engine import TritonLLMEngine
3636
from frontend.fastapi_frontend import FastApiFrontend
37+
from utils.utils import HTTP_DEFAULT_MAX_INPUT_SIZE, validate_positive_int
3738

3839

3940
def signal_handler(
@@ -197,6 +198,14 @@ def parse_args():
197198
action="append",
198199
help="Restrict access to specific OpenAI API endpoints. Format: '<API_1>,<API_2>,... <restricted-key> <restricted-value>' (e.g., 'inference,model-repository admin-key admin-value'). If not specified, all endpoints are allowed.",
199200
)
201+
openai_group.add_argument(
202+
"--http-max-input-size",
203+
type=validate_positive_int,
204+
default=HTTP_DEFAULT_MAX_INPUT_SIZE,
205+
help=f"Maximum allowed HTTP request input size in bytes for the OpenAI "
206+
f"frontend (default: {HTTP_DEFAULT_MAX_INPUT_SIZE}, i.e. 64 MiB). "
207+
"Requests exceeding this limit will be rejected.",
208+
)
200209

201210
# KServe Predict v2 Frontend
202211
kserve_group = parser.add_argument_group("Triton KServe Frontend")
@@ -269,6 +278,7 @@ def main():
269278
port=args.openai_port,
270279
log_level=args.uvicorn_log_level,
271280
restricted_apis=args.openai_restricted_api,
281+
http_max_input_size=args.http_max_input_size,
272282
)
273283
except ValueError as e:
274284
print(

python/openai/openai_frontend/utils/utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
1+
# Copyright 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
#
33
# Redistribution and use in source and binary forms, with or without
44
# modification, are permitted provided that the following conditions
@@ -26,6 +26,10 @@
2626

2727
from enum import IntEnum
2828

29+
# Default value for the --http-max-input-size CLI flag (64 MiB).
30+
# Same as HTTP_DEFAULT_MAX_INPUT_SIZE in src/common.h.
31+
HTTP_DEFAULT_MAX_INPUT_SIZE: int = 1 << 26
32+
2933

3034
class ServerError(Exception):
3135
"""Exception raised for server errors."""
@@ -44,4 +48,15 @@ class StatusCode(IntEnum):
4448
CLIENT_ERROR = 400
4549
AUTHORIZATION_ERROR = 401
4650
NOT_FOUND = 404
51+
CONTENT_TOO_LARGE = 413
4752
SERVER_ERROR = 500
53+
54+
55+
def validate_positive_int(value: object) -> int:
56+
try:
57+
ivalue = int(value)
58+
except (TypeError, ValueError):
59+
raise ValueError(f"value is not an integer, got {value!r}")
60+
if ivalue <= 0:
61+
raise ValueError(f"value must be greater than 0, got {value!r}")
62+
return ivalue

0 commit comments

Comments
 (0)