Skip to content

perf: replace tenacity with a plain retry loop - #72

Merged
lesnik512 merged 2 commits into
mainfrom
perf/retry-fast-path-71
Oct 16, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
perf/retry-fast-path-71

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Closes #71.

postgres_retry now runs a plain retry loop instead of tenacity, and tenacity is no longer a dependency. A call that succeeds first time, which is almost every call, is now one direct await inside a try.

attempt = 1
while True:
    try:
        return await f(*args, **kwargs)
    except Exception as exception:
        attempts = retries if retries is not None else settings.get_retries_number()
        if not _log_and_decide(exception) or attempt >= attempts:
            raise
    await asyncio.sleep(_backoff(attempt))
    attempt += 1

What stays the same

  • Attempt count. retries=N or DB_RETRY_RETRIES_NUMBER caps the calls of the body. It is still read on every call (ADR 0001), but only once an attempt has failed.
  • Decision order. The retriable predicate runs first, as tenacity ran it, so the "retrying" and "giving up on retry" debug lines appear exactly as before.
  • Wait between attempts. _backoff is the formula behind tenacity's wait_exponential_jitter() at its defaults: 2 ** (attempt - 1) plus uniform jitter in [0, 1). The cap is dropped, because the default cap is sys.maxsize / 2 seconds.
  • Re-raising. The last error is re-raised unchanged, as with reraise=True.

What changes

  • Dependency. tenacity is removed from dependencies. Code that imported it only because db-retry pulled it in has to declare it itself. That belongs in the release notes.
  • Logging. tenacity's before_log DEBUG line ("Starting call to ..., this is the Nth time calling it") is gone. The two db-retry debug lines remain.
  • Exception type. The loop catches Exception. Under tenacity, a BaseException such as CancelledError went through the predicate and logged "giving up on retry" before propagating. Now it propagates without the log line. It was never retried in either version.

An earlier revision of this PR kept tenacity and replayed the first error into it. That worked, but it took 24 lines to call the function once outside tenacity. The loop is 10 lines and has no dependency.

Benchmarks

Wrapping a coroutine that returns immediately, 50,000 calls, Python 3.14:

before (tenacity 9.1.4) after
postgres_retry overhead 22.0 to 22.4 µs/call 0.14 µs/call

A one-row session.get on an autocommit engine against Postgres 17 in Docker, with SQLAlchemy 2.0.54 and asyncpg 0.31.0. The table shows µs per request over two rounds with 1 client and one round with 50 clients. "Before" and "after" differ only in the db-retry code:

before after
pre-ping on, no retry (the templates today) 187 to 193 183 to 193
pre-ping off, postgres_retry + Transaction 217 to 230 173 to 181

Before this change, replacing pre-ping with the retry made requests slower; now it makes them faster.

Tests and docs

  • test_a_retriable_first_failure_waits_once_then_succeeds checks that one retriable failure costs exactly one non-zero wait and two attempts.
  • The invariant tests are unchanged apart from docstrings that named tenacity: the per-call attempt count, the wait before every retry, and the attempt counts.
  • The first revision's test, which asserted that tenacity was never built on the success path, is removed now that tenacity is gone.
  • README.md no longer lists tenacity as a requirement. CONTEXT.md and AGENTS.md no longer define "attempt" through tenacity's stop_after_attempt.
  • Lint and ty pass, and all 51 tests pass with 100% coverage.

A first attempt that succeeds no longer enters tenacity's attempt loop, so postgres_retry costs about 0.2 us per call instead of 22 us. After a retriable first failure, the retryer replays that error as attempt 1, so waits, attempt counts and logging stay the same.
The loop keeps the attempt count, the predicate-first decision, the jittered exponential wait and re-raising, and drops the tenacity dependency.
@lesnik512 lesnik512 changed the title perf: call once before building the retryer perf: replace tenacity with a plain retry loop Oct 16, 2026
@lesnik512
lesnik512 merged commit 319d0c7 into main Oct 16, 2026
12 checks passed
@lesnik512
lesnik512 deleted the perf/retry-fast-path-71 branch October 16, 2026 15:54
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.

postgres_retry costs ~21 µs per call on the success path

1 participant