details: https://code.tryton.org/tryton/commit/51728c97c24f
branch: default
user: Cédric Krier <[email protected]>
date: Wed Jul 08 22:58:48 2026 +0200
description:
Add a timer to prevent requests from lasting longer than their timeout
Closes #14946
diffstat:
trytond/CHANGELOG | 1 +
trytond/doc/ref/exceptions.rst | 4 ++
trytond/trytond/exceptions.py | 4 ++
trytond/trytond/protocols/dispatcher.py | 53 ++++++++++++++++++++++----------
trytond/trytond/tests/test_protocols.py | 51 +++++++++++++++++++++++++++++++
5 files changed, 96 insertions(+), 17 deletions(-)
diffs (204 lines):
diff -r c62abebb2795 -r 51728c97c24f trytond/CHANGELOG
--- a/trytond/CHANGELOG Tue Jul 14 19:06:52 2026 +0200
+++ b/trytond/CHANGELOG Wed Jul 08 22:58:48 2026 +0200
@@ -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
diff -r c62abebb2795 -r 51728c97c24f trytond/doc/ref/exceptions.rst
--- a/trytond/doc/ref/exceptions.rst Tue Jul 14 19:06:52 2026 +0200
+++ b/trytond/doc/ref/exceptions.rst Wed Jul 08 22:58:48 2026 +0200
@@ -31,6 +31,10 @@
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.
diff -r c62abebb2795 -r 51728c97c24f trytond/trytond/exceptions.py
--- a/trytond/trytond/exceptions.py Tue Jul 14 19:06:52 2026 +0200
+++ b/trytond/trytond/exceptions.py Wed Jul 08 22:58:48 2026 +0200
@@ -64,6 +64,10 @@
"""User has sent too many requests in a given amount of time."""
+class TimeOutException(TrytonException):
+ pass
+
+
class MissingDependenciesException(TrytonException):
def __init__(self, missings):
diff -r c62abebb2795 -r 51728c97c24f trytond/trytond/protocols/dispatcher.py
--- a/trytond/trytond/protocols/dispatcher.py Tue Jul 14 19:06:52 2026 +0200
+++ b/trytond/trytond/protocols/dispatcher.py Wed Jul 08 22:58:48 2026 +0200
@@ -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 @@
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 @@
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)
diff -r c62abebb2795 -r 51728c97c24f trytond/trytond/tests/test_protocols.py
--- a/trytond/trytond/tests/test_protocols.py Tue Jul 14 19:06:52 2026 +0200
+++ b/trytond/trytond/tests/test_protocols.py Wed Jul 08 22:58:48 2026 +0200
@@ -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 @@
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'