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
28 changes: 7 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ class User(DeclarativeBase):
# Apply retry logic to ORM operations (uses DB_RETRY_RETRIES_NUMBER, default 3)
@postgres_retry
async def get_user_by_email(session: AsyncSession, email: str) -> User:
return await session.scalar(
sa.select(User).where(User.email == email)
)
return await session.scalar(sa.select(User).where(User.email == email))


async def main():
Expand All @@ -87,8 +85,7 @@ Per-callsite retry count override:

```python
@postgres_retry(retries=5)
async def create_order(session: AsyncSession, order: Order) -> Order:
...
async def create_order(session: AsyncSession, order: Order) -> Order: ...
```

### 2. High Availability Database Connections
Expand All @@ -102,25 +99,15 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from db_retry import build_connection_factory, build_db_dsn

# Configure multiple database hosts for high availability
multi_host_dsn = (
"postgresql://user:password@/"
"myapp_db?"
"host=primary-db:5432&"
"host=secondary-db:5432&"
"host=backup-db:5432"
)
multi_host_dsn = "postgresql://user:password@/myapp_db?host=primary-db:5432&host=secondary-db:5432&host=backup-db:5432"

# Build production-ready DSN
dsn = build_db_dsn(
db_dsn=multi_host_dsn,
database_name="production_database",
drivername="postgresql+asyncpg"
)
dsn = build_db_dsn(db_dsn=multi_host_dsn, database_name="production_database", drivername="postgresql+asyncpg")

# Create connection factory with timeout
connection_factory = build_connection_factory(
url=dsn,
timeout=5.0 # 5 second connection timeout
timeout=5.0, # 5 second connection timeout
)

# Engine will automatically try different hosts on failure
Expand Down Expand Up @@ -153,8 +140,8 @@ class CreateEventUseCase:

@postgres_retry
async def __call__(
self,
event_create_data: AnalyticsEventCreate,
self,
event_create_data: AnalyticsEventCreate,
) -> AnalyticsEvent:
async with self.transaction:
model: typing.Final = EventsTable(
Expand All @@ -166,7 +153,6 @@ class CreateEventUseCase:
await self.analytics_events_producer.send_message(event)
await self.transaction.commit()
return event

```

### 4. Serializable Transactions for Consistency
Expand Down
6 changes: 3 additions & 3 deletions architecture/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ slotted `ConnectionPlan`:

```python
class ConnectionPlan:
connect_args: Mapping[str, Any] # base kwargs, minus host/port/target_session_attrs
connect_args: Mapping[str, Any] # base kwargs, minus host/port/target_session_attrs
target_session_attrs: SessionAttribute | None
primary_host: str | list[str] # list for multi-host, scalar for single
primary_host: str | list[str] # list for multi-host, scalar for single
primary_port: int | list[int] | None
failover: tuple[tuple[str, int], ...] # per-host pairs; () for single-host
failover: tuple[tuple[str, int], ...] # per-host pairs; () for single-host
```

`target_session_attrs` (e.g. `read-write`/`prefer-standby` set by
Expand Down
4 changes: 3 additions & 1 deletion planning/changes/2026-06-26.01-retriable-error-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ A named constant and one pure public function (plus a private per-link helper):
```python
RETRIABLE_ASYNCPG_ERRORS = (asyncpg.SerializationError, asyncpg.PostgresConnectionError)


def is_retriable(exception: BaseException) -> bool:
"""Walk __cause__/__context__; True if any link is a retriable DBAPIError."""
current: BaseException | None = exception
Expand Down Expand Up @@ -85,7 +86,8 @@ def _log_and_decide(exception: BaseException) -> bool:
logger.debug("postgres_retry, giving up on retry")
return False

retry=tenacity.retry_if_exception(_log_and_decide)

retry = tenacity.retry_if_exception(_log_and_decide)
```

`_is_retriable_dbapi_error` and `_retry_handler` are deleted from `retry.py`.
Expand Down
17 changes: 10 additions & 7 deletions planning/changes/2026-06-26.03-connection-plan-split.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ A frozen, slotted, keyword-only dataclass (house style, as `Transaction`):
```python
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class ConnectionPlan:
connect_args: Mapping[str, Any] # base kwargs, minus host/port/target_session_attrs
connect_args: Mapping[str, Any] # base kwargs, minus host/port/target_session_attrs
target_session_attrs: SessionAttribute | None
primary_host: str | list[str] # bulk attempt: list for multi-host, scalar for single
primary_host: str | list[str] # bulk attempt: list for multi-host, scalar for single
primary_port: int | list[int] | None
failover: tuple[tuple[str, int], ...] # per-host pairs; () for single-host
failover: tuple[tuple[str, int], ...] # per-host pairs; () for single-host
```

`build_connection_plan(url: sqlalchemy.URL) -> ConnectionPlan` does the entire
Expand All @@ -78,7 +78,7 @@ No timeout (an I/O concern), no logging, no `asyncpg.connect`.

```python
def build_connection_factory(url, timeout):
plan = build_connection_plan(url) # parse-once, at build time
plan = build_connection_plan(url) # parse-once, at build time

async def _connection_factory():
try:
Expand All @@ -87,7 +87,7 @@ def build_connection_factory(url, timeout):
if not plan.failover:
raise
logger.warning("Failed to fetch asyncpg connection. Trying host by host.")
for host, port in _reshuffled(plan.failover): # per-call re-shuffle
for host, port in _reshuffled(plan.failover): # per-call re-shuffle
try:
return await _connect(plan, host, port, timeout)
except (TimeoutError, OSError, asyncpg.TargetServerAttributeNotMatched) as exc:
Expand All @@ -104,8 +104,11 @@ def build_connection_factory(url, timeout):
```python
async def _connect(plan, host, port, timeout):
return await asyncpg.connect(
**plan.connect_args, host=host, port=port,
timeout=timeout, target_session_attrs=plan.target_session_attrs,
**plan.connect_args,
host=host,
port=port,
timeout=timeout,
target_session_attrs=plan.target_session_attrs,
)
```

Expand Down
7 changes: 5 additions & 2 deletions planning/changes/2026-06-30.01-python-3.11-3.12-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Today (3.12+ only):
type _Func[**P, T] = typing.Callable[P, typing.Coroutine[None, None, T]]
type _Decorator[**P, T] = typing.Callable[[_Func[P, T]], _Func[P, T]]


@typing.overload
def postgres_retry[**P, T](func: _Func[P, T], *, retries: int | None = ...) -> _Func[P, T]: ...
```
Expand All @@ -60,12 +61,14 @@ T = typing.TypeVar("T")
_Func: typing.TypeAlias = typing.Callable[P, typing.Coroutine[None, None, T]]
_Decorator: typing.TypeAlias = typing.Callable[[_Func], _Func]


@typing.overload
def postgres_retry(func: _Func, *, retries: int | None = ...) -> _Func: ...
@typing.overload
def postgres_retry(func: None = ..., *, retries: int | None = ...) -> _Decorator: ...
def postgres_retry(func: _Func | None = None, *, retries: int | None = None) -> _Func | _Decorator:
... # body unchanged (lines 39-55)
def postgres_retry(
func: _Func | None = None, *, retries: int | None = None
) -> _Func | _Decorator: ... # body unchanged (lines 39-55)
```

`P` and `T` move to module scope; the bare aliases re-bind them per signature,
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ ignore = [
"COM812", # flake8-commas "Trailing comma missing"
"ISC001", # flake8-implicit-str-concat
"G004", # allow f-strings in logging
"CPY001", # no per-file copyright header
]
isort.lines-after-imports = 2
isort.no-lines-before = ["standard-library", "local-folder"]
Expand Down