diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index 5fb830a665..c25fd28890 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -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 --git a/trytond/doc/ref/exceptions.rst b/trytond/doc/ref/exceptions.rst index 98ccb0ce3a..363d031ff3 100644 --- a/trytond/doc/ref/exceptions.rst +++ b/trytond/doc/ref/exceptions.rst @@ -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. diff --git a/trytond/trytond/exceptions.py b/trytond/trytond/exceptions.py index 5a2673e7dd..f72e8f5fd8 100644 --- a/trytond/trytond/exceptions.py +++ b/trytond/trytond/exceptions.py @@ -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): diff --git a/trytond/trytond/protocols/dispatcher.py b/trytond/trytond/protocols/dispatcher.py index 35b9fcb25e..8d06c5b3cf 100644 --- a/trytond/trytond/protocols/dispatcher.py +++ b/trytond/trytond/protocols/dispatcher.py @@ -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) diff --git a/trytond/trytond/tests/test_protocols.py b/trytond/trytond/tests/test_protocols.py index 36ec365e91..ae32e858da 100644 --- a/trytond/trytond/tests/test_protocols.py +++ b/trytond/trytond/tests/test_protocols.py @@ -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'