Found while measuring #25. build_redis_retry builds redis.retry.Retry, and create_async_redis_client hands that object to redis.asyncio.Redis. The async client does await conn.retry.call_with_retry(...); the sync class's call_with_retry is a plain function that calls do() once and returns the coroutine it got back, so the client awaits that coroutine outside any loop and the retry never sees the failure. redis.asyncio.retry.Retry has been a separate class with async def call_with_retry since 4.5.0 and is not a subclass of the sync one on any version in the pinned range.
Measured on redis-py 8.1.0 against a paused redis:7-alpine, socket_timeout=0.5, one GET per redis.asyncio.Redis:
sync Retry(ExponentialBackoff(1.0, 0.1), 3) -> TimeoutError 0.50 s
async Retry(ExponentialBackoff(1.0, 0.1), 3) -> TimeoutError 3.41 s
sync Retry(NoBackoff(), 0) -> TimeoutError 0.50 s
async Retry(NoBackoff(), 0) -> TimeoutError 0.50 s
So on the async side retry.enabled=True, max_attempts=3 has never retried a single-node client. The async cluster is half affected: its command loop only reads get_retries() off the object, so that part counts, but the pipeline path awaits call_with_retry and does not. The zero-retry object #25 introduces for the disabled path is unaffected, as the last two rows show — no retries need no loop.
The fix is not one line because build_redis_retry and build_base_redis_kwargs are public and shared by both factories, and they have to know which flavour they are building. Two shapes I can see: a keyword on both helpers selecting the class, or a second pair of helpers for the async side, mirroring the aio/sync split the rest of the package already has. Either way the async factory ends up handing redis.asyncio.retry.Retry, rule 6 on the agents page gets a line about it, and the async test that asserts isinstance(kwargs["retry"], redis.retry.Retry) flips to the async class. Anyone with retries enabled on an async client will see their commands start retrying, which is what they asked for, so it is a fix: whose changelog line says so.
Script
"""Does a sync redis.retry.Retry retry at all when handed to an async client?"""
import asyncio
import time
from redis.asyncio import Redis
from redis.asyncio.retry import Retry as AsyncRetry
from redis.backoff import ExponentialBackoff, NoBackoff
from redis.retry import Retry as SyncRetry
from testcontainers.redis import RedisContainer
async def timed(label, coro):
started = time.perf_counter()
try:
result = await asyncio.wait_for(coro, timeout=30)
print(f" {label:<48} -> {result!s:<15} {time.perf_counter() - started:6.2f} s")
except Exception as error: # noqa: BLE001
print(f" {label:<48} -> {type(error).__name__:<15} {time.perf_counter() - started:6.2f} s")
async def main(host, port, wrapped):
common = dict(host=host, port=port, socket_timeout=0.5, socket_connect_timeout=0.5)
clients = {
"sync Retry(ExponentialBackoff(1.0, 0.1), 3)": Redis(**common, retry=SyncRetry(ExponentialBackoff(cap=1.0, base=0.1), 3)),
"async Retry(ExponentialBackoff(1.0, 0.1), 3)": Redis(**common, retry=AsyncRetry(ExponentialBackoff(cap=1.0, base=0.1), 3)),
"sync Retry(NoBackoff(), 0)": Redis(**common, retry=SyncRetry(NoBackoff(), 0)),
"async Retry(NoBackoff(), 0)": Redis(**common, retry=AsyncRetry(NoBackoff(), 0)),
}
for client in clients.values():
await client.ping()
wrapped.pause()
print("--- Redis paused ---")
for label, client in clients.items():
await timed(label, client.get("k"))
pool = client.connection_pool
available = list(pool._available_connections)
print(f" pool after failure: available={len(available)} connected={[c.is_connected for c in available]} in_use={len(pool._in_use_connections)}")
wrapped.unpause()
for client in clients.values():
await client.aclose()
with RedisContainer("redis:7-alpine") as container:
asyncio.run(main(container.get_container_host_ip(), int(container.get_exposed_port(6379)), container.get_wrapped_container()))
Found while measuring #25.
build_redis_retrybuildsredis.retry.Retry, andcreate_async_redis_clienthands that object toredis.asyncio.Redis. The async client doesawait conn.retry.call_with_retry(...); the sync class'scall_with_retryis a plain function that callsdo()once and returns the coroutine it got back, so the client awaits that coroutine outside any loop and the retry never sees the failure.redis.asyncio.retry.Retryhas been a separate class withasync def call_with_retrysince 4.5.0 and is not a subclass of the sync one on any version in the pinned range.Measured on redis-py 8.1.0 against a paused
redis:7-alpine,socket_timeout=0.5, one GET perredis.asyncio.Redis:So on the async side
retry.enabled=True, max_attempts=3has never retried a single-node client. The async cluster is half affected: its command loop only readsget_retries()off the object, so that part counts, but the pipeline path awaitscall_with_retryand does not. The zero-retry object #25 introduces for the disabled path is unaffected, as the last two rows show — no retries need no loop.The fix is not one line because
build_redis_retryandbuild_base_redis_kwargsare public and shared by both factories, and they have to know which flavour they are building. Two shapes I can see: a keyword on both helpers selecting the class, or a second pair of helpers for the async side, mirroring theaio/syncsplit the rest of the package already has. Either way the async factory ends up handingredis.asyncio.retry.Retry, rule 6 on the agents page gets a line about it, and the async test that assertsisinstance(kwargs["retry"], redis.retry.Retry)flips to the async class. Anyone with retries enabled on an async client will see their commands start retrying, which is what they asked for, so it is afix:whose changelog line says so.Script