Skip to content

Commit b4f1d65

Browse files
committed
Merge PR #520 into 16.0
Signed-off-by lmignon
2 parents 3285f49 + a3bae8c commit b4f1d65

5 files changed

Lines changed: 141 additions & 1 deletion

File tree

base_rest_demo/services/exception_services.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright 2018 ACSONE SA/NV
22
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
33

4+
from psycopg2 import errorcodes
5+
from psycopg2.errors import OperationalError
46
from werkzeug.exceptions import MethodNotAllowed
57

68
from odoo import _
@@ -12,9 +14,13 @@
1214
ValidationError,
1315
)
1416
from odoo.http import SessionExpiredException
17+
from odoo.service.model import MAX_TRIES_ON_CONCURRENCY_FAILURE
1518

19+
from odoo.addons.base_rest.components.service import to_int
1620
from odoo.addons.component.core import Component
1721

22+
_CPT_RETRY = 0
23+
1824

1925
class ExceptionService(Component):
2026
_inherit = "base.rest.service"
@@ -90,6 +96,23 @@ def bare_exception(self):
9096
"""
9197
raise IOError("My IO error")
9298

99+
def retryable_error(self, nbr_retries):
100+
"""This method is used in the test suite to check that the retrying
101+
functionality in case of concurrency error on the database is working
102+
correctly for retryable exceptions.
103+
104+
The output will be the number of retries that have been done.
105+
106+
This method is mainly used to test the retrying functionality
107+
"""
108+
global _CPT_RETRY
109+
if _CPT_RETRY < nbr_retries:
110+
_CPT_RETRY += 1
111+
raise FakeConcurrentUpdateError("fake error")
112+
tryno = _CPT_RETRY
113+
_CPT_RETRY = 0
114+
return {"retries": tryno}
115+
93116
# Validator
94117
def _validator_user_error(self):
95118
return {}
@@ -138,3 +161,22 @@ def _validator_bare_exception(self):
138161

139162
def _validator_return_bare_exception(self):
140163
return {}
164+
165+
def _validator_retryable_error(self):
166+
return {
167+
"nbr_retries": {
168+
"type": "integer",
169+
"required": True,
170+
"default": MAX_TRIES_ON_CONCURRENCY_FAILURE,
171+
"coerce": to_int,
172+
}
173+
}
174+
175+
def _validator_return_retryable_error(self):
176+
return {"retries": {"type": "integer"}}
177+
178+
179+
class FakeConcurrentUpdateError(OperationalError):
180+
@property
181+
def pgcode(self):
182+
return errorcodes.SERIALIZATION_FAILURE

base_rest_demo/tests/test_exception.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,17 @@ def test_bare_exception(self):
104104
self.assertEqual(response.headers["content-type"], "application/json")
105105
body = json.loads(response.content.decode("utf-8"))
106106
self.assertDictEqual(body, {"code": 500, "name": "Internal Server Error"})
107+
108+
@odoo.tools.mute_logger("odoo.addons.base_rest.http", "odoo.http")
109+
def test_retrying(self):
110+
"""Test that the retrying mechanism is working as expected with the
111+
FastAPI endpoints in case of POST request with a file.
112+
"""
113+
nbr_retries = 3
114+
response = self.url_open(
115+
"%s/retryable_error" % self.url,
116+
'{"nbr_retries": %d}' % nbr_retries,
117+
timeout=20000,
118+
)
119+
self.assertEqual(response.status_code, 200, response.content)
120+
self.assertDictEqual(response.json(), {"retries": nbr_retries})

rest_log/components/service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import logging
88
import traceback
99

10+
from psycopg2.errors import OperationalError
1011
from werkzeug.urls import url_encode, url_join
1112

1213
from odoo import exceptions, registry
1314
from odoo.http import Response, request
15+
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
1416

1517
from odoo.addons.base_rest.http import JSONEncoder
1618
from odoo.addons.component.core import AbstractComponent
@@ -111,6 +113,15 @@ def _dispatch_exception(
111113
log_entry_url = self._get_log_entry_url(log_entry)
112114
except Exception as e:
113115
_logger.exception("Rest Log Error Creation: %s", e)
116+
# let the OperationalError bubble up to the retrying mechanism
117+
# We can't wrap the OperationalError because we want to let it
118+
# bubble up to the retrying mechanism, it will be handled by
119+
# the default handler at the end of the chain.
120+
if (
121+
isinstance(orig_exception, OperationalError)
122+
and orig_exception.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY
123+
):
124+
raise orig_exception
114125
raise exception_klass(exc_msg, log_entry_url) from orig_exception
115126

116127
def _get_exception_message(self, exception):

rest_log/tests/common.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
import contextlib
66

7+
from psycopg2 import errorcodes
8+
from psycopg2.errors import OperationalError
9+
710
from odoo import exceptions
811

912
from odoo.addons.base_rest import restapi
@@ -42,6 +45,7 @@ def fail(self, how):
4245
"value": ValueError,
4346
"validation": exceptions.ValidationError,
4447
"user": exceptions.UserError,
48+
"retryable": FakeConcurrentUpdateError,
4549
}
4650
raise exc[how]("Failed as you wanted!")
4751

@@ -61,3 +65,9 @@ def _get_mocked_request(self, env=None, httprequest=None, extra_headers=None):
6165
headers.update(extra_headers or {})
6266
mocked_request.httprequest.headers = headers
6367
yield mocked_request
68+
69+
70+
class FakeConcurrentUpdateError(OperationalError):
71+
@property
72+
def pgcode(self):
73+
return errorcodes.SERIALIZATION_FAILURE

rest_log/tests/test_db_logging.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from odoo.addons.component.tests.common import new_rollbacked_env
1414
from odoo.addons.rest_log import exceptions as log_exceptions # pylint: disable=W7950
1515

16-
from .common import TestDBLoggingMixin
16+
from .common import FakeConcurrentUpdateError, TestDBLoggingMixin
1717

1818

1919
class TestDBLogging(TransactionRestServiceRegistryCase, TestDBLoggingMixin):
@@ -374,3 +374,66 @@ def test_log_exception_value(self):
374374
self._test_exception(
375375
"value", log_exceptions.RESTServiceDispatchException, "ValueError", "severe"
376376
)
377+
378+
379+
class TestDBLoggingRetryableError(
380+
TransactionRestServiceRegistryCase, TestDBLoggingMixin
381+
):
382+
@classmethod
383+
def setUpClass(cls):
384+
super().setUpClass()
385+
cls._setup_registry(cls)
386+
387+
@classmethod
388+
def tearDownClass(cls):
389+
# pylint: disable=W8110
390+
cls._teardown_registry(cls)
391+
super().tearDownClass()
392+
393+
def _test_exception(self, test_type, wrapping_exc, exc_name, severity):
394+
log_model = self.env["rest.log"].sudo()
395+
initial_entries = log_model.search([])
396+
# Context: we are running in a transaction case which uses savepoints.
397+
# The log machinery is going to rollback the transation when catching errors.
398+
# Hence we need a completely separated env for the service.
399+
with new_rollbacked_env() as new_env:
400+
# Init fake collection w/ new env
401+
collection = _PseudoCollection(self._collection_name, new_env)
402+
service = self._get_service(self, collection=collection)
403+
with self._get_mocked_request(env=new_env):
404+
try:
405+
service.dispatch("fail", test_type)
406+
except Exception as err:
407+
# Not using `assertRaises` to inspect the exception directly
408+
self.assertTrue(isinstance(err, wrapping_exc))
409+
self.assertEqual(
410+
service._get_exception_message(err), "Failed as you wanted!"
411+
)
412+
413+
with new_rollbacked_env() as new_env:
414+
log_model = new_env["rest.log"].sudo()
415+
entry = log_model.search([]) - initial_entries
416+
expected = {
417+
"collection": service._collection,
418+
"state": "failed",
419+
"result": "null",
420+
"exception_name": exc_name,
421+
"exception_message": "Failed as you wanted!",
422+
"severity": severity,
423+
}
424+
self.assertRecordValues(entry, [expected])
425+
426+
@staticmethod
427+
def _get_test_controller(class_or_instance, root_path=None):
428+
return super()._get_test_controller(
429+
class_or_instance, root_path="/test_log_exception_retryable/"
430+
)
431+
432+
def test_log_exception_retryable(self):
433+
# retryable error must bubble up to the retrying mechanism
434+
self._test_exception(
435+
"retryable",
436+
FakeConcurrentUpdateError,
437+
"odoo.addons.rest_log.tests.common.FakeConcurrentUpdateError",
438+
"warning",
439+
)

0 commit comments

Comments
 (0)