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'
 

Reply via email to