Add a timer to prevent requests from lasting longer than their timeout
Closes #14946
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add a timer to prevent requests from lasting longer than their timeout
|
||||
* Apply mixin to Database and TableHandler from configuration
|
||||
* Restrict protocols allowed for weasyprint to http(s) (issue14947)
|
||||
* Add search attribute to XML record tag
|
||||
|
||||
@@ -31,6 +31,10 @@ Exceptions
|
||||
|
||||
The exception raised when user has sent too many login requests.
|
||||
|
||||
.. exception:: TimeOutException
|
||||
|
||||
The exception raised when a request last longer then its timeout.
|
||||
|
||||
.. exception:: MissingDependenciesException(missings)
|
||||
|
||||
The exception raised when modules are missing.
|
||||
|
||||
@@ -64,6 +64,10 @@ class RateLimitException(TrytonException):
|
||||
"""User has sent too many requests in a given amount of time."""
|
||||
|
||||
|
||||
class TimeOutException(TrytonException):
|
||||
pass
|
||||
|
||||
|
||||
class MissingDependenciesException(TrytonException):
|
||||
|
||||
def __init__(self, missings):
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
import ctypes
|
||||
import logging
|
||||
import pydoc
|
||||
import threading
|
||||
import time
|
||||
|
||||
from trytond import __series__, backend, config, security
|
||||
from trytond.exceptions import (
|
||||
ConcurrencyException, LoginException, RateLimitException, UserError,
|
||||
UserWarning)
|
||||
ConcurrencyException, LoginException, RateLimitException, TimeOutException,
|
||||
UserError, UserWarning)
|
||||
from trytond.rpc import RPCReturnException
|
||||
from trytond.tools import is_instance_method
|
||||
from trytond.tools.logging import format_args
|
||||
@@ -213,6 +215,10 @@ def _dispatch(request, pool, *args, **kwargs):
|
||||
format_args(args, kwargs, logger.isEnabledFor(logging.DEBUG)),
|
||||
username, request.remote_addr, request.path)
|
||||
|
||||
def raise_timeout(ident):
|
||||
ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||||
ctypes.c_ulong(ident), ctypes.py_object(TimeOutException))
|
||||
|
||||
def duration():
|
||||
return (time.monotonic() - started) * 1000
|
||||
started = time.monotonic()
|
||||
@@ -220,35 +226,48 @@ def _dispatch(request, pool, *args, **kwargs):
|
||||
retry = config.getint('database', 'retry')
|
||||
count = 0
|
||||
transaction_extras = {}
|
||||
timer = None
|
||||
while True:
|
||||
if timer is not None:
|
||||
timer.cancel()
|
||||
if count:
|
||||
time.sleep(0.02 * count)
|
||||
with Transaction().start(
|
||||
pool.database_name, user,
|
||||
readonly=rpc.readonly, timeout=rpc.timeout,
|
||||
**transaction_extras) as transaction:
|
||||
if rpc.timeout:
|
||||
timer = threading.Timer(
|
||||
rpc.timeout, raise_timeout, (threading.get_ident(),))
|
||||
timer.start()
|
||||
try:
|
||||
c_args, c_kwargs, transaction.context, transaction.timestamp \
|
||||
= rpc.convert(obj, *args, **kwargs)
|
||||
transaction.context['_request'] = request.context
|
||||
meth = rpc.decorate(getattr(obj, method))
|
||||
if (rpc.instantiate is None
|
||||
or not is_instance_method(obj, method)):
|
||||
result = rpc.result(meth(*c_args, **c_kwargs))
|
||||
else:
|
||||
assert rpc.instantiate == 0
|
||||
inst = c_args.pop(0)
|
||||
if hasattr(inst, method):
|
||||
result = rpc.result(meth(inst, *c_args, **c_kwargs))
|
||||
try:
|
||||
(c_args, c_kwargs,
|
||||
transaction.context, transaction.timestamp) \
|
||||
= rpc.convert(obj, *args, **kwargs)
|
||||
transaction.context['_request'] = request.context
|
||||
meth = rpc.decorate(getattr(obj, method))
|
||||
if (rpc.instantiate is None
|
||||
or not is_instance_method(obj, method)):
|
||||
result = rpc.result(meth(*c_args, **c_kwargs))
|
||||
else:
|
||||
result = [rpc.result(meth(i, *c_args, **c_kwargs))
|
||||
for i in inst]
|
||||
assert rpc.instantiate == 0
|
||||
inst = c_args.pop(0)
|
||||
if hasattr(inst, method):
|
||||
result = rpc.result(
|
||||
meth(inst, *c_args, **c_kwargs))
|
||||
else:
|
||||
result = [rpc.result(meth(i, *c_args, **c_kwargs))
|
||||
for i in inst]
|
||||
finally:
|
||||
if timer is not None:
|
||||
timer.cancel()
|
||||
except TransactionError as e:
|
||||
transaction.rollback()
|
||||
transaction.tasks.clear()
|
||||
e.fix(transaction_extras)
|
||||
continue
|
||||
except backend.DatabaseTimeoutError:
|
||||
except (backend.DatabaseTimeoutError, TimeOutException):
|
||||
logger.warning(
|
||||
log_message, *log_args, duration(), exc_info=True)
|
||||
abort(HTTPStatus.GATEWAY_TIMEOUT)
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from trytond import config
|
||||
from trytond.model import ModelStorage
|
||||
from trytond.pool import Pool
|
||||
from trytond.protocols.jsonrpc import JSONDecoder, JSONEncoder, JSONRequest
|
||||
from trytond.protocols.wrappers import (
|
||||
@@ -137,6 +141,53 @@ class XMLTestCase(DumpsLoadsMixin, TestCase):
|
||||
self.assertEqual(result, Decimal('3.141592653589793'))
|
||||
|
||||
|
||||
class RPCTimeout(RouteTestCase):
|
||||
module = 'res'
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
timeout = config.get('request', 'timeout')
|
||||
config.set('request', 'timeout', '1')
|
||||
cls.addClassCleanup(config.set, 'request', 'timeout', timeout)
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def setUpDatabase(cls):
|
||||
pool = Pool()
|
||||
User = pool.get('res.user')
|
||||
User.create([{
|
||||
'name': 'user',
|
||||
'login': 'user',
|
||||
'password': 'password',
|
||||
}])
|
||||
|
||||
def test_timeout(self):
|
||||
"Test RPC with timeout"
|
||||
basic_auth = 'Basic ' + b64encode(b"user:password").decode()
|
||||
response = self.client().post(
|
||||
f'/{self.db_name}/rpc/',
|
||||
json={
|
||||
'method': 'model.ir.model.search',
|
||||
'params': [[], {}],
|
||||
},
|
||||
headers=[('Authorization', basic_auth)])
|
||||
self.assertEqual(response.status_code, HTTPStatus.OK)
|
||||
|
||||
def test_timeout_exceeded(self):
|
||||
"Test RPC with timeout exceeded"
|
||||
with patch.object(ModelStorage, 'search') as search:
|
||||
search.side_effect = lambda *a, **k: time.sleep(2)
|
||||
basic_auth = 'Basic ' + b64encode(b"user:password").decode()
|
||||
response = self.client().post(
|
||||
f'/{self.db_name}/rpc/',
|
||||
json={
|
||||
'method': 'model.ir.model.search',
|
||||
'params': [[], {}],
|
||||
},
|
||||
headers=[('Authorization', basic_auth)])
|
||||
self.assertEqual(response.status_code, HTTPStatus.GATEWAY_TIMEOUT)
|
||||
|
||||
|
||||
class UserApplication(RouteTestCase):
|
||||
module = 'res'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user