Skip to content

fix: hand redis-py a zero-retry Retry when retries are disabled - #27

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/disabled-retries-mean-zero-retries
Sep 7, 2026
Merged

fix: hand redis-py a zero-retry Retry when retries are disabled#27
AlexeyShalaev merged 1 commit into
masterfrom
fix/disabled-retries-mean-zero-retries

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

With retry.enabled=False — the default — the factory handed redis-py no Retry, and redis-py fills that in with a policy of its own: Retry(ExponentialWithJitterBackoff(base=1, cap=10), retries=3) since 6.0.0, retries=10 since 8.0.0. Rule 5 on the agents page and the retry section of the configuration guide said the opposite, and a client built with socket_timeout=0.5 took 10 to 18 seconds to fail against an unreachable Redis instead of half a second.

What changed

build_redis_retry always returns a Retry: the kit's exponential one when enabled and max_attempts are both set, Retry(NoBackoff(), 0) otherwise. build_base_redis_kwargs always carries it, so single and cluster clients, async and sync, get it through the one path they already share; the factories are untouched. The return type narrows from Retry | None to Retry — a caller that checked for None keeps working, and one that handed the result straight to Redis(retry=...) stops inheriting redis-py's default.

Rule 5, the enabled row and the build_redis_retry row on the agents page, and the retry section of the configuration guide now say what the code does.

What I checked in redis-py

One venv per version, reading the retry default off inspect.signature and the connection classes off the source.

redis-py Redis / asyncio.Redis retry= default cluster clients
4.5.0 – 5.3.1 None; the connection turns it into Retry(NoBackoff(), 0) the object goes to the node connections; the cluster-level loop is cluster_error_retry_attempts=3
6.0.0 – 7.x Retry(ExponentialWithJitterBackoff, retries=3) the object drives the cluster-level loop, default 3
8.0.0 – 8.1.0 same, retries=10 same, default 10

retry= on all four client classes, redis.backoff.NoBackoff and Retry(NoBackoff(), 0) in both redis.retry and redis.asyncio.retry are all there at 4.5.0, so nothing here needs a floor bump. Below 6.0 the explicit object is exactly what "nothing" used to mean for single clients. For cluster clients on 6.0+, retries=0 also stops the cluster-level reinitialise-and-retry rounds, which is what "disabled" says. On 4.x/5.x the sync cluster's node connections already defaulted to zero; the async cluster's defaulted to Retry(default_backoff(), 3), so an async cluster on an old redis-py is the one place behaviour moves, from three node-level retries to none.

What I rejected

Substituting the zero-retry object only inside build_base_redis_kwargs and leaving build_redis_retry returning None. Smaller diff, but build_redis_retry is exported from the root, and anyone assembling their own kwargs from it would keep the trap.

Jitter on the kit's own backoff. ExponentialWithJitterBackoff is not in redis-py 4.x, so it is not a one-line swap under redis>=4.5.0, and rule 6 promises the first retry waits exactly backoff_base. Its own issue if wanted.

Tests

Unit: the kwargs handed to the client carry a Retry with zero retries and NoBackoff when retries are off, for single and cluster, async and sync; the enabled-path tests are unchanged and still pass. Integration: a GET against a paused redis:7-alpine with socket_timeout=0.5 and retries off raises TimeoutError in under two seconds, async and sync. All eight fail on master — the integration pair at about nine seconds each — and pass here.

The probe from the issue, before and after

retry_probe.py, unmodified, in a fresh venv with this tree installed editable and redis-py 8.1.0.

Before, on master:

  bare redis-py, defaults                                    retry handed to redis-py: Retry retries=10 backoff=ExponentialWithJitterBackoff
  bare redis-py, timeouts 0.5 + Retry(NoBackoff(), 0)        retry handed to redis-py: Retry retries=0 backoff=NoBackoff
  kit, timeouts 0.5, retry settings default                  retry handed to redis-py: Retry retries=10 backoff=ExponentialWithJitterBackoff
  kit, timeouts 0.5, retry.enabled=False explicitly          retry handed to redis-py: Retry retries=10 backoff=ExponentialWithJitterBackoff
--- Redis paused: one GET per client ---
  bare redis-py, defaults                                    -> still waiting         20.01 s  (gave up watching)
  bare redis-py, timeouts 0.5 + Retry(NoBackoff(), 0)        -> TimeoutError           0.50 s
  kit, timeouts 0.5, retry settings default                  -> TimeoutError           9.29 s
  kit, timeouts 0.5, retry.enabled=False explicitly          -> TimeoutError           9.87 s

After, on this branch:

  bare redis-py, defaults                                    retry handed to redis-py: Retry retries=10 backoff=ExponentialWithJitterBackoff
  bare redis-py, timeouts 0.5 + Retry(NoBackoff(), 0)        retry handed to redis-py: Retry retries=0 backoff=NoBackoff
  kit, timeouts 0.5, retry settings default                  retry handed to redis-py: Retry retries=0 backoff=NoBackoff
  kit, timeouts 0.5, retry.enabled=False explicitly          retry handed to redis-py: Retry retries=0 backoff=NoBackoff
--- Redis paused: one GET per client ---
  bare redis-py, defaults                                    -> still waiting         20.00 s  (gave up watching)
  bare redis-py, timeouts 0.5 + Retry(NoBackoff(), 0)        -> TimeoutError           0.50 s
  kit, timeouts 0.5, retry settings default                  -> TimeoutError           0.50 s
  kit, timeouts 0.5, retry.enabled=False explicitly          -> TimeoutError           0.50 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: 138 passed, coverage 99.64% against the 90% floor. uv.lock untouched. CHANGELOG.md is release-please's.

Follow-up

While measuring, the probe showed that with retries enabled the async factory hands redis.retry.Retry — the sync class — to redis.asyncio.Redis, whose execute_command awaits call_with_retry; the sync one runs no loop, so max_attempts=3 on an async client fails in 0.50 s where redis.asyncio.retry.Retry takes 3.41 s. Not folded in here; it is #26.

Closes #25

With retry.enabled=False the factory passed no Retry at all, and redis-py
retries on its own when given none: three times since 6.0, ten since 8.0,
with jittered backoff. A client built with socket_timeout=0.5 therefore
took 10 to 18 seconds to fail against an unreachable Redis.

build_redis_retry now always returns a Retry, Retry(NoBackoff(), 0) when
retries are off, and build_base_redis_kwargs always carries it, so single
and cluster clients, async and sync, get it alike. Rule 5 and the API row
on the agents page and the retry section of the configuration guide say
so.
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.

Disabled retries still retry: redis-py's default Retry(10) applies when the kit hands it nothing

1 participant