Skip to content

Commit 4a08d9e

Browse files
Allow retry_on_exception to decorate a regular function as well as an instance method + allow to prepend custom error message
Summary: `retry_on_exception` can now: 1) be applied to a standalone function as well as to an instance method, 2) accept a string kwarg that will act as a prefix to the error message raised after all the retries (if the final try still fails), which will help make error messages more helpful to users. I didn't actually end up needing this functionality for the stuff I was writing, but figured this will be good to check in anyway. Reviewed By: stevemandala Differential Revision: D21819541 fbshipit-source-id: 6854dfe25636664b2f98695367eb95a96ad4fb3c
1 parent 41db887 commit 4a08d9e

File tree

2 files changed

+60
-35
lines changed

2 files changed

+60
-35
lines changed

ax/utils/common/executils.py

+44-26
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,14 @@ def retry_on_exception(
1717
suppress_all_errors: bool = False,
1818
logger: Optional[Logger] = None,
1919
default_return_on_suppression: Optional[Any] = None,
20+
wrap_error_message_in: Optional[str] = None,
2021
) -> Optional[Any]:
2122
"""
22-
A decorator **for instance methods** to be retried on failure.
23+
A decorator for instance methods or standalone functions that makes them
24+
retry on failure and allows to specify on which types of exceptions the
25+
function should and should not retry.
2326
24-
Warnings:
25-
If the variable `check_message_contains` is supplied and the error message
26-
contains any of the strings provided, the error will be suppressed
27-
and default value returned.
28-
29-
If the variable `suppress_all_errors` is supplied and set to True,
27+
NOTE: If the argument `suppress_all_errors` is supplied and set to True,
3028
the error will be suppressed and default value returned.
3129
3230
Args:
@@ -37,27 +35,41 @@ def retry_on_exception(
3735
if their supertype appears in `exception_types` or the only exceptions to
3836
not retry on if no `exception_types` are specified.
3937
40-
check_message_contains: A list of strings to be provided. If the error
41-
message contains any one of these messages, the exception will
42-
be suppressed.
38+
check_message_contains: A list of strings, against which to match error
39+
messages. If the error message contains any one of these strings,
40+
the exception will cause a retry. NOTE: This argument works in
41+
addition to `exception_types`; if those are specified, only the
42+
specified types of exceptions will be caught and retried on if they
43+
contain the strings provided as `check_message_contains`.
4344
44-
retries: Number of retries.
45+
retries: Number of retries to perform.
4546
46-
suppress_all_errors: A flag which will allow you to suppress all exceptions
47-
of the type provided after all retries have been exhausted.
47+
suppress_all_errors: If true, after all the retries are exhausted, the
48+
error will still be suppressed and `default_return_on_suppresion`
49+
will be returned from the function. NOTE: If using this argument,
50+
the decorated function may not actually get fully executed, if
51+
it consistently raises an exception.
4852
4953
logger: A handle for the logger to be used.
5054
51-
default_return_on_suppression: If the error is suppressed, then the default
52-
value to be returned once all retries are exhausted.
55+
default_return_on_suppression: If the error is suppressed after all the
56+
retries, then this default value will be returned from the function.
57+
Defaults to None.
58+
59+
wrap_error_message_in: If raising the error message after all the retries,
60+
a string wrapper for the error message (useful for making error
61+
messages more user-friendly). NOTE: Format of resulting error will be:
62+
"<wrap_error_message_in>: <original_error_type>: <original_error_msg>",
63+
with the stack trace of the original message.
64+
5365
"""
5466

5567
def func_wrapper(func):
5668
@functools.wraps(func)
57-
def actual_wrapper(self, *args, **kwargs):
69+
def actual_wrapper(*args, **kwargs):
5870
retriable_exceptions = exception_types
5971
if exception_types is None:
60-
# If no exception type provided, we catch all errors
72+
# If no exception type provided, we catch all errors.
6173
retriable_exceptions = (Exception,)
6274
if not isinstance(retriable_exceptions, tuple):
6375
raise ValueError("Expected a tuple of exception types.")
@@ -75,17 +87,15 @@ def actual_wrapper(self, *args, **kwargs):
7587
"`exception_types` and `no_retry_on_exception_types`."
7688
)
7789

78-
suppress_errors = False
79-
if suppress_all_errors or (
90+
# `suppress_all_errors` could be a flag to the underlying function
91+
# when used on instance methods.
92+
suppress_errors = suppress_all_errors or (
8093
"suppress_all_errors" in kwargs and kwargs["suppress_all_errors"]
81-
):
82-
# If we are provided with a flag to suppress all errors
83-
# inside either the function kwargs or the decorator parameters
84-
suppress_errors = True
94+
)
8595

8696
for i in range(retries):
8797
try:
88-
return func(self, *args, **kwargs)
98+
return func(*args, **kwargs)
8999
except no_retry_on_exception_types or ():
90100
raise
91101
except retriable_exceptions as err: # Exceptions is a tuple.
@@ -102,9 +112,17 @@ def actual_wrapper(self, *args, **kwargs):
102112
# In this case, the error is just logged, suppressed and default
103113
# value returned
104114
if logger is not None:
105-
logger.exception(err)
115+
logger.exception(
116+
wrap_error_message_in
117+
) # Automatically logs `err` and its stack trace.
106118
continue
107-
raise
119+
if not wrap_error_message_in:
120+
raise
121+
else:
122+
msg = (
123+
f"{wrap_error_message_in}: {type(err).__name__}: {str(err)}"
124+
)
125+
raise type(err)(msg).with_traceback(err.__traceback__)
108126
# If we are here, it means the retries were finished but
109127
# The error was somehow suppressed. Hence return the default value provided
110128
return default_return_on_suppression

ax/utils/common/tests/test_executils.py

+16-9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# LICENSE file in the root directory of this source tree.
66

77
import logging
8+
from unittest.mock import Mock
89

910
from ax.utils.common.executils import retry_on_exception
1011
from ax.utils.common.testutils import TestCase
@@ -212,15 +213,21 @@ def error_throwing_function(self):
212213
with self.assertRaises(MyRuntimeError):
213214
decorator_tester.error_throwing_function()
214215

215-
self.assertEqual(decorator_tester.error_throwing_function_call_count, 1)
216+
def test_on_function_with_wrapper_message(self):
217+
"""Tests that the decorator works on standalone functions as well as on
218+
instance methods.
219+
"""
216220

217-
with self.assertRaisesRegex(ValueError, "Same exception"):
221+
mock = Mock()
218222

219-
@retry_on_exception(
220-
exception_types=(MyRuntimeError,),
221-
no_retry_on_exception_types=(MyRuntimeError,),
222-
)
223-
def incorrectly_decorated_function(self, arg):
224-
pass
223+
@retry_on_exception(wrap_error_message_in="Wrapper error message")
224+
def error_throwing_function():
225+
mock()
226+
raise RuntimeError("I failed")
227+
228+
with self.assertRaisesRegex(
229+
RuntimeError, "Wrapper error message: RuntimeError: I failed"
230+
):
231+
error_throwing_function()
225232

226-
incorrectly_decorated_function(None, None)
233+
self.assertEqual(mock.call_count, 3)

0 commit comments

Comments
 (0)