Add a timer to prevent requests from lasting longer than their timeout

Closes #14946
This commit is contained in:
Cédric Krier
2026-07-08 22:58:48 +02:00
parent fd2c35cd77
commit 25a22e3f9a
5 changed files with 96 additions and 17 deletions
+1
View File
@@ -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
+4
View File
@@ -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.
+4
View File
@@ -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):
+36 -17
View File
@@ -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)
+51
View File
@@ -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'