Skip to content

fix: hand redis.asyncio.Redis the async Retry, not the sync one - #29

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/async-client-gets-async-retry
Sep 7, 2026
Merged

fix: hand redis.asyncio.Redis the async Retry, not the sync one#29
AlexeyShalaev merged 1 commit into
masterfrom
fix/async-client-gets-async-retry

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Contributor

build_redis_retry built redis.retry.Retry for both factories, and create_async_redis_client handed that to redis.asyncio.Redis. The sync class's call_with_retry is a plain function: it calls do() once and returns the coroutine it got, so the client awaited that coroutine outside the retry loop, and retry.enabled=True, max_attempts=N never retried on an async client — single node on every redis-py in the range, and the async cluster's pipeline path with it. The zero-retry object from #27 was the same class, which happened not to matter: no retries need no loop.

What changed

build_redis_retry(settings, *, asyncio=False) and build_base_redis_kwargs(settings, *, asyncio=False) build redis.retry.Retry by default, exactly as before, and redis.asyncio.retry.Retry with asyncio=True — the exponential object and the zero-retry one alike. The async factory passes asyncio=True for single and cluster; the sync factory is untouched. build_redis_retry carries Literal overloads, so a sync caller still sees redis.retry.Retry from the type checker and an async caller sees the async class, with a bool overload for code that passes the flag through.

The two API rows, rule 5 and a "common mistakes" block on the agents page, and the retry section of the configuration guide say what the code does.

What I checked in redis-py

One venv per version, the same ten as for #25, reading the classes off the source. redis.asyncio.retry.Retry is there from 4.5.0 through 8.1.0, never a subclass of redis.retry.Retry, always with async def call_with_retry and the same (backoff, retries, supported_errors) constructor. On every one of those versions the async Connection.connect and Redis.execute_command both await ...retry.call_with_retry(...), so the fix behaves the same across the range and moves no floor.

What I rejected

Swapping the class inside the async factory alone. build_base_redis_kwargs and build_redis_retry are exported from the root, and anyone building a redis.asyncio.Redis from them by hand would keep the trap — the reason #27 put the zero-retry object in the helper rather than in the factory.

A second pair of helpers for the async side, mirroring the aio/sync split. Two more root exports and two more rows on the agents page for one bit of information, and build_base_redis_kwargs would either be duplicated or wrap the sync one and patch retry, which is the rejected shape one level down.

Tests

Unit: the async factory's retry, single and cluster, is redis.asyncio.retry.Retry with retries enabled (test_resilience.py, now parametrized over cluster) and with retries off (test_client.py); both helpers build the async class with asyncio=True and the sync class by default (test_utils.py). Integration: an async client with max_attempts=3, backoff_base=0.1, backoff_cap=1.0 and socket_timeout=0.5 against a paused redis:7-alpine raises TimeoutError after 3.0 to 6.0 s — four attempts plus 0.2 + 0.4 + 0.8 s of backoff — with a sync twin. The sync unit tests are unchanged and still pin redis.retry.Retry.

With master's utils.py and aio/factory.py swapped back in under the new tests, those eight fail — the three helper tests, the two enabled and two disabled async factory tests, and the async integration test at 0.50 s — and the ten sync and retries-off ones pass.

Before and after

One GET per kit-built client, retries enabled as above, socket_timeout=0.5, against the paused container, redis-py 8.1.0.

master:

  kit async, enabled max_attempts=3        retry handed to redis-py: redis.retry.Retry retries=3 backoff=ExponentialBackoff
  kit sync,  enabled max_attempts=3        retry handed to redis-py: redis.retry.Retry retries=3 backoff=ExponentialBackoff
--- Redis paused: one GET per client ---
  kit async, enabled max_attempts=3        -> TimeoutError      0.50 s
  kit sync,  enabled max_attempts=3        -> TimeoutError      3.42 s

this branch:

  kit async, enabled max_attempts=3        retry handed to redis-py: redis.asyncio.retry.Retry retries=3 backoff=ExponentialBackoff
  kit sync,  enabled max_attempts=3        retry handed to redis-py: redis.retry.Retry retries=3 backoff=ExponentialBackoff
--- Redis paused: one GET per client ---
  kit async, enabled max_attempts=3        -> TimeoutError      3.41 s
  kit sync,  enabled max_attempts=3        -> TimeoutError      3.42 s

Gate

make check: ruff check All checks passed!, ruff format 54 files already formatted, mypy Success: no issues found in 25 source files. make test: 144 passed, coverage 99.64% against the 90% floor. uv.lock untouched. CHANGELOG.md is release-please's.

Closes #26

build_redis_retry built redis.retry.Retry for both factories, and the
async factory handed that to redis.asyncio.Redis. The sync class's
call_with_retry does not await, so the client awaited the coroutine
outside the retry loop and retry.enabled=True, max_attempts=N never
retried on an async client: 0.50 s to fail against a paused Redis with
socket_timeout=0.5, where the sync client took 3.42 s.

Both helpers take asyncio=False now; the async factory passes True and
gets redis.asyncio.retry.Retry, for the exponential object and for the
zero-retry one alike. The sync path is unchanged. The API rows and rule
5 on the agents page and the retry section of the configuration guide
say so.
@AlexeyShalaev
AlexeyShalaev merged commit e8ee302 into master Sep 7, 2026
6 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the fix/async-client-gets-async-retry branch September 7, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async factory hands redis.asyncio.Redis the sync Retry, so enabled retries never retry on async clients

1 participant