I have some tasks that I'd like to memoize because they connect to a rather slow network and have to wait for the data. Unfortunately this network can be a little finnicky and we get occasional connection issues. Thus I'd like if my memoizer could know to retry a certain number of times.
import functools
# Python 3.3
def enum(*sequential, **named):
enums = dict(zip(sequential, range(len(sequential))), **named)
return type('Enum', (), enums)
RetryTypes = enum("TRUE", "FALSE", "REMEMBER_EXCEPTION")
class Memoizer:
def __init__(self, retry=RetryTypes.FALSE, retry_times=0, exception_types=Exception):
self.retry = retry
self.retry_times = retry_times
self.exception_types = exception_types
def __call__(self, function):
d = {}
# Retry but don't store exceptions
if self.retry is RetryTypes.TRUE and self.retry_times > 0:
@functools.wraps(function)
def wrapper(*args, forget=False):
if args not in d or forget:
# Try n-1 times and suppress exceptions
for i in range(self.retry_times-1):
try:
d[args] = function(*args)
except self.exception_types:
continue
else:
break
else:
# If we have to try n times don't suppress any exception
d[args] = function(*args)
return d[args]
# Retry and store any exceptions
elif self.retry is RetryTypes.REMEMBER_EXCEPTION:
@functools.wraps(function)
def wrapper(*args, forget=False):
if args not in d or forget:
# If we're retrying at all, try n-1 times and suppress exceptions
if self.retry_times > 1:
for i in range(self.retry_times-1):
try:
d[args] = function(*args)
except self.exception_types:
continue
else:
break
else:
# If we have to try n times catch the exception and store it
try:
d[args] = function(*args)
except Exception as e:
d[args] = e
# If we're not retrying, just catch any exception and store it
else:
try:
d[args] = function(*args)
except Exception as e:
d[args] = e
if isinstance(d[args], Exception):
raise d[args]
else:
return d[args]
# Don't retry
else:
@functools.wraps(function)
def wrapper(*args, forget=False):
if args not in d or forget:
d[args] = function(*args)
return d[args]
return wrapper
Tests:
import math
import unittest
from memoizer import Memoizer, RetryTypes
class TestNoRetry(unittest.TestCase):
def test_no_retry_no_error(self):
@Memoizer()
def f(a):
return 1745*a
result = f(19)
self.assertIs(result, f(19))
def test_no_retry_with_error(self):
@Memoizer()
def f(a):
raise TypeError
with self.assertRaises(TypeError):
f(17)
def test_retry_no_error(self):
@Memoizer(retry=RetryTypes.TRUE)
def f(a):
return 123987/a
result = f(245)
self.assertIs(result, f(245))
def test_retry_no_error_retry_times(self):
@Memoizer(retry=RetryTypes.TRUE, retry_times=2)
def f(a):
return 123987/a
result = f(245)
self.assertIs(result, f(245))
def test_retry_error_suppressed(self):
global time
time = 0
times = 2
@Memoizer(retry=RetryTypes.TRUE, retry_times=times)
def f(a):
global time
time += 1
if time < times:
raise TypeError
else:
return math.pow(a, a)
result = f(13)
self.assertIs(result, f(13))
def test_retry_other_error_not_suppressed(self):
global time
time = 0
times = 2
@Memoizer(retry=RetryTypes.TRUE, retry_times=times, exception_types=AttributeError)
def f(a):
global time
time += 1
if time < times:
raise OSError
else:
return math.pow(a, a)
with self.assertRaises(OSError):
f(13)
def test_retry_too_many_errors(self):
@Memoizer(retry=RetryTypes.TRUE, retry_times=2)
def f(a):
raise OSError
with self.assertRaises(OSError):
f(13)
def test_no_retry_cache_errors_no_error(self):
@Memoizer(retry=RetryTypes.REMEMBER_EXCEPTION)
def f(a):
return 129384716/a
result = f(245)
self.assertIs(result, f(245))
def test_retry_cache_errors_no_error(self):
@Memoizer(retry=RetryTypes.REMEMBER_EXCEPTION, retry_times=2)
def f(a):
return 129384716/a
result = f(245)
self.assertIs(result, f(245))
def test_no_retry_cache_errors_error(self):
@Memoizer(retry=RetryTypes.REMEMBER_EXCEPTION)
def f(a):
raise OSError
error = None
try:
f(245)
except OSError as e:
error = e
self.assertIsNot(error, None)
try:
f(245)
except OSError as e:
self.assertIs(e, error)
def test_retry_cache_errors_error(self):
@Memoizer(retry=RetryTypes.REMEMBER_EXCEPTION, retry_times=2)
def f(a):
raise OSError
error = None
try:
f(245)
except OSError as e:
error = e
self.assertIsNot(error, None)
try:
f(245)
except OSError as e:
self.assertIs(e, error)
def test_retry_cache_errors_eventual_success(self):
global time
time = 0
times = 2
@Memoizer(retry=RetryTypes.REMEMBER_EXCEPTION, retry_times=2)
def f(a):
global time
time += 1
if time < times:
raise OSError
else:
import random
return a & random.getrandbits(64)
result = f(213125)
self.assertIs(result, f(213125))
if __name__ == '__main__':
unittest.main()
Everything works as expected, but I'm not a huge fan of the __call__
implementation, and I feel as though I'm missing some test cases. I'm also not positive which, if any, of the test values would be cached or interned anyway. I also wonder if this might be well served by making d
a weakref.WeakValueDictionary
.
else
case of the try-except blocks; I do exit the loop early. You're right - putting forget first will short circuit the boolean, but I tend to assume that I'm not very likely to setforget=True
, and short circuiting the boolean isn't going to give me that much of a performance boost anyway, considering that dictionary lookup isO(1)
* anyway \$\endgroup\$