Skip to content

Commit 8fe4fc5

Browse files
Extend RedisSettings to include redis Retry Helper settings (#387)
* extend RedisSettings retry settings * fix type and settings test * add redis.Retry type * fix test to allow arbitrary types * add testing for retry settings * update tests * granular patch handling * update comment * stop patch when exists * update retry type to asyncio * chore: test cleanup * fix exception type --------- Co-authored-by: Samuel Colvin <[email protected]>
1 parent 8321dc1 commit 8fe4fc5

File tree

4 files changed

+128
-3
lines changed

4 files changed

+128
-3
lines changed

arq/connections.py

+8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from uuid import uuid4
1010

1111
from redis.asyncio import ConnectionPool, Redis
12+
from redis.asyncio.retry import Retry
1213
from redis.asyncio.sentinel import Sentinel
1314
from redis.exceptions import RedisError, WatchError
1415

@@ -47,6 +48,10 @@ class RedisSettings:
4748
sentinel: bool = False
4849
sentinel_master: str = 'mymaster'
4950

51+
retry_on_timeout: bool = False
52+
retry_on_error: Optional[List[Exception]] = None
53+
retry: Optional[Retry] = None
54+
5055
@classmethod
5156
def from_dsn(cls, dsn: str) -> 'RedisSettings':
5257
conf = urlparse(dsn)
@@ -254,6 +259,9 @@ def pool_factory(*args: Any, **kwargs: Any) -> ArqRedis:
254259
ssl_ca_certs=settings.ssl_ca_certs,
255260
ssl_ca_data=settings.ssl_ca_data,
256261
ssl_check_hostname=settings.ssl_check_hostname,
262+
retry=settings.retry,
263+
retry_on_timeout=settings.retry_on_timeout,
264+
retry_on_error=settings.retry_on_error,
257265
)
258266

259267
while True:

tests/conftest.py

+40
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
import msgpack
77
import pytest
8+
import redis.exceptions
9+
from redis.asyncio.retry import Retry
10+
from redis.backoff import NoBackoff
811

912
from arq.connections import ArqRedis, create_pool
1013
from arq.worker import Worker
@@ -44,6 +47,21 @@ async def arq_redis_msgpack(loop):
4447
await redis_.close(close_connection_pool=True)
4548

4649

50+
@pytest.fixture
51+
async def arq_redis_retry(loop):
52+
redis_ = ArqRedis(
53+
host='localhost',
54+
port=6379,
55+
encoding='utf-8',
56+
retry=Retry(backoff=NoBackoff(), retries=3),
57+
retry_on_timeout=True,
58+
retry_on_error=[redis.exceptions.ConnectionError],
59+
)
60+
await redis_.flushall()
61+
yield redis_
62+
await redis_.close(close_connection_pool=True)
63+
64+
4765
@pytest.fixture
4866
async def worker(arq_redis):
4967
worker_: Worker = None
@@ -61,6 +79,28 @@ def create(functions=[], burst=True, poll_delay=0, max_jobs=10, arq_redis=arq_re
6179
await worker_.close()
6280

6381

82+
@pytest.fixture
83+
async def worker_retry(arq_redis_retry):
84+
worker_retry_: Worker = None
85+
86+
def create(functions=[], burst=True, poll_delay=0, max_jobs=10, arq_redis=arq_redis_retry, **kwargs):
87+
nonlocal worker_retry_
88+
worker_retry_ = Worker(
89+
functions=functions,
90+
redis_pool=arq_redis,
91+
burst=burst,
92+
poll_delay=poll_delay,
93+
max_jobs=max_jobs,
94+
**kwargs,
95+
)
96+
return worker_retry_
97+
98+
yield create
99+
100+
if worker_retry_:
101+
await worker_retry_.close()
102+
103+
64104
@pytest.fixture(name='create_pool')
65105
async def fix_create_pool(loop):
66106
pools = []

tests/test_utils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_settings_changed():
2121
"RedisSettings(host='localhost', port=123, unix_socket_path=None, database=0, username=None, password=None, "
2222
"ssl=False, ssl_keyfile=None, ssl_certfile=None, ssl_cert_reqs='required', ssl_ca_certs=None, "
2323
'ssl_ca_data=None, ssl_check_hostname=False, conn_timeout=1, conn_retries=5, conn_retry_delay=1, '
24-
"sentinel=False, sentinel_master='mymaster')"
24+
"sentinel=False, sentinel_master='mymaster', retry_on_timeout=False, retry_on_error=None, retry=None)"
2525
) == str(settings)
2626

2727

@@ -109,7 +109,7 @@ def test_typing():
109109

110110

111111
def test_redis_settings_validation():
112-
class Settings(BaseModel):
112+
class Settings(BaseModel, arbitrary_types_allowed=True):
113113
redis_settings: RedisSettings
114114

115115
@field_validator('redis_settings', mode='before')

tests/test_worker.py

+78-1
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
import signal
66
import sys
77
from datetime import datetime, timedelta, timezone
8-
from unittest.mock import MagicMock
8+
from unittest.mock import MagicMock, patch
99

1010
import msgpack
1111
import pytest
12+
import redis.exceptions
1213

1314
from arq.connections import ArqRedis, RedisSettings
1415
from arq.constants import abort_jobs_ss, default_queue_name, expires_extra_ms, health_check_key_suffix, job_key_prefix
@@ -1024,3 +1025,79 @@ async def test_worker_timezone_defaults_to_system_timezone(worker):
10241025
worker = worker(functions=[func(foobar)])
10251026
assert worker.timezone is not None
10261027
assert worker.timezone == datetime.now().astimezone().tzinfo
1028+
1029+
1030+
@pytest.mark.parametrize(
1031+
'exception_thrown',
1032+
[
1033+
redis.exceptions.ConnectionError('Error while reading from host'),
1034+
redis.exceptions.TimeoutError('Timeout reading from host'),
1035+
],
1036+
)
1037+
async def test_worker_retry(mocker, worker_retry, exception_thrown):
1038+
# Testing redis exceptions, with retry settings specified
1039+
worker = worker_retry(functions=[func(foobar)])
1040+
1041+
# patch db read_response to mimic connection exceptions
1042+
p = patch.object(worker.pool.connection_pool.connection_class, 'read_response', side_effect=exception_thrown)
1043+
1044+
# baseline
1045+
await worker.main()
1046+
await worker._poll_iteration()
1047+
1048+
# spy method handling call_with_retry failure
1049+
spy = mocker.spy(worker.pool, '_disconnect_raise')
1050+
1051+
try:
1052+
# start patch
1053+
p.start()
1054+
1055+
# assert exception thrown
1056+
with pytest.raises(type(exception_thrown)):
1057+
await worker._poll_iteration()
1058+
1059+
# assert retry counts and no exception thrown during '_disconnect_raise'
1060+
assert spy.call_count == 4 # retries setting + 1
1061+
assert spy.spy_exception is None
1062+
1063+
finally:
1064+
# stop patch to allow worker cleanup
1065+
p.stop()
1066+
1067+
1068+
@pytest.mark.parametrize(
1069+
'exception_thrown',
1070+
[
1071+
redis.exceptions.ConnectionError('Error while reading from host'),
1072+
redis.exceptions.TimeoutError('Timeout reading from host'),
1073+
],
1074+
)
1075+
async def test_worker_crash(mocker, worker, exception_thrown):
1076+
# Testing redis exceptions, no retry settings specified
1077+
worker = worker(functions=[func(foobar)])
1078+
1079+
# patch db read_response to mimic connection exceptions
1080+
p = patch.object(worker.pool.connection_pool.connection_class, 'read_response', side_effect=exception_thrown)
1081+
1082+
# baseline
1083+
await worker.main()
1084+
await worker._poll_iteration()
1085+
1086+
# spy method handling call_with_retry failure
1087+
spy = mocker.spy(worker.pool, '_disconnect_raise')
1088+
1089+
try:
1090+
# start patch
1091+
p.start()
1092+
1093+
# assert exception thrown
1094+
with pytest.raises(type(exception_thrown)):
1095+
await worker._poll_iteration()
1096+
1097+
# assert no retry counts and exception thrown during '_disconnect_raise'
1098+
assert spy.call_count == 1
1099+
assert spy.spy_exception == exception_thrown
1100+
1101+
finally:
1102+
# stop patch to allow worker cleanup
1103+
p.stop()

0 commit comments

Comments
 (0)