Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ repository.
`db-retry` is a library of retry, connection, and transaction helpers for PostgreSQL applications
built on SQLAlchemy's asyncio extension and asyncpg; [`CONTEXT.md`](CONTEXT.md) opens with what it
does and owns the vocabulary — read it before naming a concept in code, a test name, or an issue
title. The words most easily got wrong here are the ones this package shares with PostgreSQL and
with tenacity: what "retriable" covers, what a "retry" counts, and what "primary" does not mean.
title. The words most easily got wrong here are the ones this package shares with PostgreSQL: what
"retriable" covers, what a "retry" counts, and what "primary" does not mean.

## Commands

Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ is safe, and the two come apart: `StatementCompletionUnknownError` (`40003`) is

**Attempt**:
One call of the wrapped coroutine. `postgres_retry(retries=N)` bounds *attempts*, not retries — `N`
goes to tenacity's `stop_after_attempt`, so `retries=1` runs the body once and never re-runs it,
caps the calls of the body, so `retries=1` runs the body once and never re-runs it,
and the `DB_RETRY_RETRIES_NUMBER` default of `3` allows two re-runs.
_Avoid_: retry, as a count. The knob is spelled `retries` for callers and cannot be renamed without
breaking them, but every number in this package counts attempts.
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ export DB_RETRY_RETRIES_NUMBER=5
- Python 3.11+
- SQLAlchemy with asyncio support
- asyncpg PostgreSQL driver
- tenacity for retry logic

## 📦 [PyPI](https://pypi.org/project/db-retry)

Expand Down
26 changes: 16 additions & 10 deletions db_retry/retry.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import asyncio
import functools
import logging
import random
import typing

import tenacity

from db_retry import settings
from db_retry.retriable import is_retriable

Expand All @@ -19,6 +19,10 @@ def _log_and_decide(exception: BaseException) -> bool:
return False


def _backoff(attempt: int) -> float:
return 2 ** (attempt - 1) + random.uniform(0, 1) # noqa: S311


P = typing.ParamSpec("P")
T = typing.TypeVar("T")

Expand All @@ -42,14 +46,16 @@ def postgres_retry(
def decorator(f: _Func[P, T]) -> _Func[P, T]:
@functools.wraps(f)
async def wrapped_method(*args: P.args, **kwargs: P.kwargs) -> T:
retryer = tenacity.AsyncRetrying(
stop=tenacity.stop_after_attempt(retries if retries is not None else settings.get_retries_number()),
wait=tenacity.wait_exponential_jitter(),
retry=tenacity.retry_if_exception(_log_and_decide),
reraise=True,
before=tenacity.before_log(logger, logging.DEBUG),
)
return await retryer(f, *args, **kwargs)
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

return wrapped_method

Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ classifiers = [
]
version = "0"
dependencies = [
"tenacity>=8.4.1",
# 2.0.31 is the first release that tolerates the __static_attributes__ and __firstlineno__
# attributes Python 3.13 added; below it SQLAlchemy's TypingOnly check fails at import.
# 2.0.20, not 2.0.18: advanced-alchemy requires it, so nothing here can exercise 2.0.18.
Expand Down
30 changes: 23 additions & 7 deletions tests/test_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def _retriable_error() -> DBAPIError:


def _record_backoff(monkeypatch: pytest.MonkeyPatch) -> list[float]:
"""Replace the retryer's sleep with a recorder, so attempt counts cost no wall time."""
"""Replace the retry loop's sleep with a recorder, so attempt counts cost no wall time."""
waits: list[float] = []

async def _record(delay: float) -> None:
Expand Down Expand Up @@ -119,17 +119,16 @@ async def _always_fails() -> None:
await _always_fails()
assert attempts == int(configured)

assert waits, "asyncio.sleep is no longer the retryer's sleep; this test would wait out real backoff"
assert waits, "asyncio.sleep is no longer the retry loop's sleep; this test would wait out real backoff"


async def test_a_retry_never_re_runs_the_body_immediately(monkeypatch: pytest.MonkeyPatch) -> None:
"""INVARIANT: every retry is separated from the attempt before it by a non-zero wait.

Broken by dropping ``wait=tenacity.wait_exponential_jitter()`` from the retryer, or swapping it
for ``wait_none()`` -- a change that keeps every attempt-count assertion in this file green,
because the counts do not depend on the delay. Waiting is the reason to reach for tenacity at
all rather than a bare loop: both retriable classes are failures of an overloaded or unreachable
server, and re-running instantly turns one serialization conflict into a spin against the same
Broken by dropping the ``asyncio.sleep`` from the retry loop, or making ``_backoff`` return
``0`` -- a change that keeps every attempt-count assertion in this file green, because the
counts do not depend on the delay. Both retriable classes are failures of an overloaded or
unreachable server, and re-running instantly turns one serialization conflict into a spin against the same
row and one refused connection into a flood at a node that is already down. Tuning the curve --
the initial delay, the base, how much jitter -- stays within the invariant; removing the wait
does not.
Expand Down Expand Up @@ -208,3 +207,20 @@ async def select_one() -> int | None:

assert await select_one() == 1
assert attempts == 2 # noqa: PLR2004 # the closed connection, then a fresh one


async def test_a_retriable_first_failure_waits_once_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
waits: typing.Final = _record_backoff(monkeypatch)
attempts = 0

@postgres_retry(retries=3)
async def _fails_once() -> int:
nonlocal attempts
attempts += 1
if attempts == 1:
raise _retriable_error()
return attempts

assert await _fails_once() == 2 # noqa: PLR2004 # the failed attempt, then the one that succeeds
assert len(waits) == 1
assert waits[0] > 0
Loading