Skip to content

Commit 5716a67

Browse files
Merge pull request #608 from tableau/jichikawa/handle-413-payload-response
Handle 413 Request Entity Too Large
2 parents fe0a6e3 + 647f2a6 commit 5716a67

File tree

7 files changed

+136
-11
lines changed

7 files changed

+136
-11
lines changed

CHANGELOG

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v2.8.0
4+
5+
### Improvements
6+
7+
- Returns 413 error code when request payload exceeds
8+
TABPY_MAX_REQUEST_SIZE_MB config setting.
9+
310
## v2.7.0
411

512
### Improvements

tabpy/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.7.1
1+
2.8.0

tabpy/tabpy_server/app/app.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
import shutil
77
import signal
88
import sys
9+
import _thread
10+
11+
import tornado
12+
from tornado.http1connection import HTTP1Connection
13+
914
import tabpy
15+
import tabpy.tabpy_server.app.arrow_server as pa
1016
from tabpy.tabpy import __version__
1117
from tabpy.tabpy_server.app.app_parameters import ConfigParameters, SettingsParameters
1218
from tabpy.tabpy_server.app.util import parse_pwd_file
@@ -26,9 +32,6 @@
2632
StatusHandler,
2733
UploadDestinationHandler,
2834
)
29-
import tornado
30-
import tabpy.tabpy_server.app.arrow_server as pa
31-
import _thread
3235

3336
logger = logging.getLogger(__name__)
3437

@@ -62,6 +65,7 @@ class TabPyApp:
6265
python_service = None
6366
credentials = {}
6467
arrow_server = None
68+
max_request_size = None
6569

6670
def __init__(self, config_file):
6771
if config_file is None:
@@ -116,11 +120,7 @@ def _get_arrow_server(self, config):
116120

117121
def run(self):
118122
application = self._create_tornado_web_app()
119-
max_request_size = (
120-
int(self.settings[SettingsParameters.MaxRequestSizeInMb]) * 1024 * 1024
121-
)
122-
logger.info(f"Setting max request size to {max_request_size} bytes")
123-
123+
124124
init_model_evaluator(self.settings, self.tabpy_state, self.python_service)
125125

126126
protocol = self.settings[SettingsParameters.TransferProtocol]
@@ -142,8 +142,8 @@ def run(self):
142142
application.listen(
143143
self.settings[SettingsParameters.Port],
144144
ssl_options=ssl_options,
145-
max_buffer_size=max_request_size,
146-
max_body_size=max_request_size,
145+
max_buffer_size=self.max_request_size,
146+
max_body_size=self.max_request_size,
147147
**settings,
148148
)
149149

@@ -354,6 +354,12 @@ def _parse_config(self, config_file):
354354
].lower()
355355

356356
self._validate_transfer_protocol_settings()
357+
358+
# Set max request size in bytes
359+
self.max_request_size = (
360+
int(self.settings[SettingsParameters.MaxRequestSizeInMb]) * 1024 * 1024
361+
)
362+
logger.info(f"Setting max request size to {self.max_request_size} bytes")
357363

358364
# if state.ini does not exist try and create it - remove
359365
# last dependence on batch/shell script
@@ -497,3 +503,16 @@ def _build_tabpy_state(self):
497503
logger.info(f"Loading state from state file {state_file_path}")
498504
tabpy_state = _get_state_from_file(state_file_dir)
499505
return tabpy_state, TabPyState(config=tabpy_state, settings=self.settings)
506+
507+
508+
# Override _read_body to allow content with size exceeding max_body_size
509+
# This enables proper handling of 413 errors in base_handler
510+
def _read_body_allow_max_size(self, code, headers, delegate):
511+
if "Content-Length" in headers:
512+
content_length = int(headers["Content-Length"])
513+
if content_length > self._max_body_size:
514+
return
515+
return self.original_read_body(code, headers, delegate)
516+
517+
HTTP1Connection.original_read_body = HTTP1Connection._read_body
518+
HTTP1Connection._read_body = _read_body_allow_max_size

tabpy/tabpy_server/handlers/base_handler.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def initialize(self, app):
127127
self.username = None
128128
self.password = None
129129
self.eval_timeout = self.settings[SettingsParameters.EvaluateTimeout]
130+
self.max_request_size = app.max_request_size
130131

131132
self.logger = ContextLoggerWrapper(self.request)
132133
self.logger.enable_context_logging(
@@ -442,3 +443,25 @@ def fail_with_auth_error(self):
442443
info="Not Acceptable",
443444
log_message="Username or password provided when authentication not available.",
444445
)
446+
447+
def request_body_size_within_limit(self):
448+
"""
449+
Determines if the request body size is within the specified limit.
450+
451+
Returns
452+
-------
453+
bool
454+
True if the request body size is within the limit, False otherwise.
455+
"""
456+
if self.max_request_size is not None:
457+
if "Content-Length" in self.request.headers:
458+
content_length = int(self.request.headers["Content-Length"])
459+
if content_length > self.max_request_size:
460+
self.error_out(
461+
413,
462+
info="Request Entity Too Large",
463+
log_message=f"Request with size {content_length} exceeded limit of {self.max_request_size} (bytes).",
464+
)
465+
return False
466+
467+
return True

tabpy/tabpy_server/handlers/evaluation_plane_handler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def post(self):
4646
if self.should_fail_with_auth_error() != AuthErrorStates.NONE:
4747
self.fail_with_auth_error()
4848
return
49+
50+
if not self.request_body_size_within_limit():
51+
return
52+
4953
self.error_out(404, "Ad-hoc scripts have been disabled on this analytics extension, please contact your "
5054
"administrator.")
5155

@@ -165,6 +169,9 @@ def post(self):
165169
if self.should_fail_with_auth_error() != AuthErrorStates.NONE:
166170
self.fail_with_auth_error()
167171
return
172+
173+
if not self.request_body_size_within_limit():
174+
return
168175

169176
self._add_CORS_header()
170177
try:

tabpy/tabpy_server/handlers/query_plane_handler.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ def get(self, endpoint_name):
217217
self.fail_with_auth_error()
218218
return
219219

220+
if not self.request_body_size_within_limit():
221+
return
222+
220223
start = time.time()
221224
endpoint_name = urllib.parse.unquote(endpoint_name)
222225
self._process_query(endpoint_name, start)
@@ -229,6 +232,9 @@ def post(self, endpoint_name):
229232
self.fail_with_auth_error()
230233
return
231234

235+
if not self.request_body_size_within_limit():
236+
return
237+
232238
start = time.time()
233239
endpoint_name = urllib.parse.unquote(endpoint_name)
234240
self._process_query(endpoint_name, start)

tests/unit/server_tests/test_evaluation_plane_handler.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import base64
2+
import json
23
import os
34
import tempfile
5+
import string
46

57
from tornado.testing import AsyncHTTPTestCase
68

@@ -458,6 +460,67 @@ def test_evaluation_enabled(self):
458460
)
459461
self.assertEqual(200, response.code)
460462

463+
class TestEvaluationPlaneHandlerMaxRequestSize(AsyncHTTPTestCase):
464+
@classmethod
465+
def setUpClass(cls):
466+
prefix = "__TestEvaluationPlaneHandlerMaxRequestSize_"
467+
468+
# create config file
469+
cls.config_file = tempfile.NamedTemporaryFile(
470+
mode="w+t", prefix=prefix, suffix=".conf", delete=False
471+
)
472+
cls.config_file.write(
473+
"[TabPy]\n"
474+
"TABPY_MAX_REQUEST_SIZE_MB = 1"
475+
)
476+
cls.config_file.close()
477+
478+
@classmethod
479+
def tearDownClass(cls):
480+
os.remove(cls.config_file.name)
481+
482+
def get_app(self):
483+
self.app = TabPyApp(self.config_file.name)
484+
return self.app._create_tornado_web_app()
485+
486+
def create_large_payload(self):
487+
num_chars = 2 * 1024 * 1024 # 2MB Size
488+
large_string = string.printable * (num_chars // len(string.printable))
489+
large_string += string.printable[:num_chars % len(string.printable)]
490+
payload = {
491+
"data": { "_arg1": [1, large_string] },
492+
"script": "return _arg1"
493+
}
494+
return json.dumps(payload).encode('utf-8')
495+
496+
def test_evaluation_payload_exceeds_max_request_size(self):
497+
response = self.fetch(
498+
"/evaluate",
499+
method="POST",
500+
body=self.create_large_payload()
501+
)
502+
self.assertEqual(413, response.code)
503+
504+
def test_evaluation_max_request_size_not_applied(self):
505+
self.app.max_request_size = None
506+
response = self.fetch(
507+
"/evaluate",
508+
method="POST",
509+
body=self.create_large_payload()
510+
)
511+
self.assertEqual(200, response.code)
512+
self.assertEqual(1, json.loads(response.body)[0])
513+
514+
def test_no_content_length_header_present(self):
515+
response = self.fetch(
516+
"/evaluate",
517+
method="POST",
518+
allow_nonstandard_methods=True
519+
)
520+
message = json.loads(response.body)["message"]
521+
# Ensure it reaches script processing stage in EvaluationPlaneHandler.post
522+
self.assertEqual("Error processing script", message)
523+
461524

462525
class TestEvaluationPlaneHandlerDefault(AsyncHTTPTestCase):
463526
@classmethod

0 commit comments

Comments
 (0)