diff --git a/README.md b/README.md index 5634123..f175a3e 100644 --- a/README.md +++ b/README.md @@ -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(): @@ -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 @@ -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 @@ -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( @@ -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 diff --git a/architecture/connections.md b/architecture/connections.md index 6c52e59..07ddbfb 100644 --- a/architecture/connections.md +++ b/architecture/connections.md @@ -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 diff --git a/planning/changes/2026-06-26.01-retriable-error-seam.md b/planning/changes/2026-06-26.01-retriable-error-seam.md index 5482dfa..c235167 100644 --- a/planning/changes/2026-06-26.01-retriable-error-seam.md +++ b/planning/changes/2026-06-26.01-retriable-error-seam.md @@ -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 @@ -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`. diff --git a/planning/changes/2026-06-26.03-connection-plan-split.md b/planning/changes/2026-06-26.03-connection-plan-split.md index 61fb2b0..9e10f77 100644 --- a/planning/changes/2026-06-26.03-connection-plan-split.md +++ b/planning/changes/2026-06-26.03-connection-plan-split.md @@ -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 @@ -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: @@ -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: @@ -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, ) ``` diff --git a/planning/changes/2026-06-30.01-python-3.11-3.12-support.md b/planning/changes/2026-06-30.01-python-3.11-3.12-support.md index 90a6dbb..e2879bb 100644 --- a/planning/changes/2026-06-30.01-python-3.11-3.12-support.md +++ b/planning/changes/2026-06-30.01-python-3.11-3.12-support.md @@ -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]: ... ``` @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 532e4b3..dffa863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]