Skip to content

Commit 46c00ea

Browse files
authored
feat: size Granian to the pool and return 503 on pool timeouts (#77)
Add APP_WORKERS, DB_POOL_TIMEOUT and DB_POOL_RECYCLE. Set Granian backpressure to pool size plus overflow so excess requests wait in the socket backlog instead of on the pool, and require a positive pool size and non-negative overflow so that sum stays meaningful. A pool checkout timeout, raw or wrapped by advanced-alchemy in RepositoryError, now returns 503 instead of 500.
1 parent a9439ee commit 46c00ea

9 files changed

Lines changed: 99 additions & 6 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`.
3636

3737
`UseCases` group (request-scoped): one provider per use case in `app/use_cases/`. modern-di fills each dataclass field by its type, so a use case needs no `kwargs`.
3838

39+
**Pool and server sizing**: `DB_POOL_SIZE` (positive) and `DB_MAX_OVERFLOW` (non-negative) size the pool, and `app/__main__.py` sets Granian's `backpressure` to their sum, so each worker accepts only as many requests at once as it has connections. Requests beyond that wait in Granian's socket backlog instead of queueing on the pool. `APP_WORKERS` sets the Granian worker count, and each worker has its own pool. `DB_POOL_TIMEOUT` (default 30 s) bounds the wait for a connection, and a timeout returns 503 through `exceptions.database_unavailable_handler`. advanced-alchemy wraps the timeout in a `RepositoryError`, so the handler is also registered for `RepositoryError` and answers anything not caused by a pool timeout the way Litestar's default handler would: the debug page when `SERVICE_DEBUG` is on, a plain 500 otherwise. `DB_POOL_RECYCLE` defaults to SQLAlchemy's `-1` (never). Set it below the idle timeout of any proxy or load balancer in front of Postgres, so a connection it dropped is not handed out and retried after a 1-2 s backoff.
40+
3941
**Use cases and lost connections**: `pool_pre_ping` is off by default (`DB_POOL_PRE_PING`). A pooled connection that died while idle is handled by `db_retry.postgres_retry` instead, which saves a round trip on every checkout. That only works if the retried function owns its whole transaction, so every use case has this shape:
4042

4143
```python
@@ -65,15 +67,15 @@ class ListDecksUseCase:
6567

6668
**Migrations**: `migrations/env.py` reads `app.models.METADATA` and rewrites the DSN driver from `postgresql+asyncpg` → `postgresql+psycopg` (Alembic runs on sync psycopg 3). Always run autogen against an upgraded DB — `just migration` enforces this.
6769

68-
**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`.
70+
**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). Pool and server sizing uses `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` and `APP_WORKERS` (see **Pool and server sizing**). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`.
6971

7072
## Conventions
7173

7274
- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/<name>.py`, defining handlers + a `ROUTER`, and adding it to that list. Its database work goes in `app/use_cases/` with a provider in `ioc.UseCases`.
7375
- Pydantic schemas in `app/schemas.py` use `from_attributes=True` (via `Base`) so they validate directly from ORM instances (`schemas.X.model_validate(orm_instance)`). Collection responses go through `Collection[T].from_models(...)` (e.g. `schemas.Cards`).
7476
- List endpoints page by id (keyset). They take `limit` (1 to `schemas.MAX_PAGE_LIMIT`, default `schemas.DEFAULT_PAGE_LIMIT`) and `after`, the last id the client has seen, and return a `Page[T]` subclass (`schemas.DecksPage`, `schemas.CardsPage`) with `items` and `next_after`. The repository filters `id > after` and fetches `limit + 1` rows ordered by id. `Page.from_models(rows, limit=limit)` drops the extra row and sets `next_after` to the last returned id when that row existed, or `null` on the last page. Don't page with OFFSET: Postgres reads and discards every skipped row.
7577
- Deck responses are deliberately two-shaped: `list_decks`/`create_deck`/`update_deck` return the light `schemas.Deck` (no `cards`), while `get_deck` returns `schemas.DeckWithCards`. The split mirrors loading — lists use `noload` (no cards query), detail uses `joinedload` via `fetch_with_cards` — so the type states exactly what each endpoint loads.
76-
- Domain exceptions: register handlers in `application.build_app`'s `exception_handlers` dict (`DuplicateKeyError` → `exceptions.duplicate_key_error_handler` returns 400, `NotFoundError` → `exceptions.not_found_error_handler` returns 404). Handlers don't raise HTTP errors themselves: a missing row surfaces as advanced-alchemy's `NotFoundError`.
78+
- Domain exceptions: register handlers in `application.build_app`'s `exception_handlers` dict (`DuplicateKeyError` → `exceptions.duplicate_key_error_handler` returns 400, `NotFoundError` and `ForeignKeyError` → `exceptions.not_found_error_handler` returns 404, `sqlalchemy.exc.TimeoutError` and `RepositoryError` → `exceptions.database_unavailable_handler` returns 503 for a pool timeout). Handlers don't raise HTTP errors themselves: a missing row surfaces as advanced-alchemy's `NotFoundError`.
7779
- `ruff` is configured with `select = ["ALL"]` and a line length of 120 — expect strict lint. `app/resources/` is exempt from the `TC` rules because modern-di resolves DI creator annotations at runtime. Type-check with `ty`; suppressions already exist for `invalid-argument-type` around `LifespanManager` / `ASGITransport` in `tests/conftest.py`.
7880

7981
## Agent skills

‎app/__main__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
factory=True,
1111
address=settings.app_host,
1212
port=settings.app_port,
13+
workers=settings.app_workers,
14+
backpressure=settings.db_pool_size + settings.db_max_overflow,
1315
interface=Interfaces.ASGI,
1416
log_level=settings.log_level,
1517
loop=Loops.uvloop,

‎app/application.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
import modern_di
55
import modern_di_litestar
6-
from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError
6+
from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError, RepositoryError
77
from lite_bootstrap import LitestarBootstrapper
88
from litestar.config.app import AppConfig
99
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
10+
from sqlalchemy import exc as sa_exc
1011

1112
from app import exceptions, ioc
1213
from app.api import cards, decks
@@ -26,6 +27,8 @@ def build_app() -> litestar.Litestar:
2627
DuplicateKeyError: exceptions.duplicate_key_error_handler,
2728
NotFoundError: exceptions.not_found_error_handler,
2829
ForeignKeyError: exceptions.not_found_error_handler,
30+
sa_exc.TimeoutError: exceptions.database_unavailable_handler,
31+
RepositoryError: exceptions.database_unavailable_handler,
2932
},
3033
route_handlers=[decks.ROUTER, cards.ROUTER],
3134
plugins=[modern_di_litestar.ModernDIPlugin(di_container, autowired_groups=[ioc.UseCases])],

‎app/exceptions.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import litestar
44
from litestar import status_codes
5+
from litestar.exceptions.responses import create_debug_response, create_exception_response
6+
from sqlalchemy import exc as sa_exc
57

68

79
if typing.TYPE_CHECKING:
@@ -22,3 +24,14 @@ def not_found_error_handler(_: object, __: NotFoundError | ForeignKeyError) -> l
2224
content={"detail": "Not found"},
2325
status_code=status_codes.HTTP_404_NOT_FOUND,
2426
)
27+
28+
29+
def database_unavailable_handler(request: litestar.Request, exc: Exception) -> litestar.Response[typing.Any]:
30+
if not isinstance(exc, sa_exc.TimeoutError) and not isinstance(exc.__cause__, sa_exc.TimeoutError):
31+
fallback: typing.Final = create_debug_response if request.app.debug else create_exception_response
32+
return fallback(request=request, exc=exc)
33+
return litestar.Response(
34+
media_type=litestar.MediaType.JSON,
35+
content={"detail": "Database unavailable"},
36+
status_code=status_codes.HTTP_503_SERVICE_UNAVAILABLE,
37+
)

‎app/resources/db.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine:
2424
pool_size=settings.db_pool_size,
2525
pool_pre_ping=settings.db_pool_pre_ping,
2626
max_overflow=settings.db_max_overflow,
27+
pool_timeout=settings.db_pool_timeout,
28+
pool_recycle=settings.db_pool_recycle,
2729
isolation_level="AUTOCOMMIT" if read_only else None,
2830
connect_args={"server_settings": {"default_transaction_read_only": "on"}} if read_only else {},
2931
)

‎app/settings.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ class Settings(pydantic_settings.BaseSettings):
1616

1717
db_dsn: str = "postgresql+asyncpg://postgres:password@db/postgres"
1818
db_replica_dsn: str = ""
19-
db_pool_size: int = 5
20-
db_max_overflow: int = 0
19+
db_pool_size: pydantic.PositiveInt = 5
20+
db_max_overflow: pydantic.NonNegativeInt = 0
2121
db_pool_pre_ping: bool = False
22+
db_pool_timeout: float = 30
23+
db_pool_recycle: int = -1
2224

2325
app_host: str = "0.0.0.0" # noqa: S104
2426
app_port: int = 8000
27+
app_workers: int = 1
2528

2629
opentelemetry_endpoint: str = ""
2730
sentry_dsn: str = ""

‎tests/test_main.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,25 @@
66
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from app import ioc
9+
from app.settings import settings
910

1011

1112
if TYPE_CHECKING:
1213
import pytest
1314

1415

1516
def test_main(monkeypatch: pytest.MonkeyPatch) -> None:
16-
monkeypatch.setattr("granian.Granian", mock.Mock())
17+
granian = mock.Mock()
18+
monkeypatch.setattr("granian.Granian", granian)
19+
monkeypatch.setattr(settings, "app_workers", 4)
20+
monkeypatch.setattr(settings, "db_pool_size", 5)
21+
monkeypatch.setattr(settings, "db_max_overflow", 2)
22+
1723
runpy.run_module("app.__main__", run_name="__main__")
1824

25+
assert granian.call_args.kwargs["workers"] == 4 # noqa: PLR2004
26+
assert granian.call_args.kwargs["backpressure"] == 7 # noqa: PLR2004
27+
1928

2029
async def test_session() -> None:
2130
container = modern_di.Container(groups=ioc.ALL_GROUPS)

‎tests/test_pool.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import typing
2+
import uuid
3+
4+
import pytest
5+
from litestar import status_codes as status
6+
7+
from app import ioc
8+
from app.resources.db import create_primary_sa_engine
9+
from app.settings import settings
10+
from tests import factories
11+
12+
13+
if typing.TYPE_CHECKING:
14+
import modern_di
15+
from httpx import AsyncClient
16+
17+
18+
@pytest.mark.parametrize(
19+
"request_kwargs",
20+
[
21+
{"method": "get", "url": "/api/decks/1/"},
22+
{"method": "post", "url": "/api/decks/", "json": {"name": "pool", "idempotency_key": str(uuid.uuid4())}},
23+
],
24+
)
25+
async def test_exhausted_pool_returns_503(
26+
client: AsyncClient,
27+
di_container: modern_di.Container,
28+
monkeypatch: pytest.MonkeyPatch,
29+
request_kwargs: dict[str, typing.Any],
30+
) -> None:
31+
monkeypatch.setattr(settings, "db_pool_size", 1)
32+
monkeypatch.setattr(settings, "db_max_overflow", 0)
33+
monkeypatch.setattr(settings, "db_pool_timeout", 0.1)
34+
engine: typing.Final = create_primary_sa_engine()
35+
di_container.override(ioc.Database.dynamic_engine, engine)
36+
try:
37+
async with engine.connect():
38+
response = await client.request(**request_kwargs)
39+
finally:
40+
di_container.reset_override(ioc.Database.dynamic_engine)
41+
await engine.dispose()
42+
43+
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
44+
45+
46+
@pytest.mark.usefixtures("set_async_session_in_base_sqlalchemy_factory")
47+
async def test_other_repository_errors_are_not_turned_into_503(client: AsyncClient) -> None:
48+
deck: typing.Final = await factories.DeckModelFactory.create_async()
49+
50+
response: typing.Final = await client.post(f"/api/decks/{deck.id}/cards/", json=[{"front": "nul \x00 byte"}])
51+
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR

‎tests/test_settings.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
22

3+
import pydantic
4+
import pytest
35
from granian.log import LogLevels
46

57
from app.settings import Settings
@@ -23,3 +25,9 @@ def test_log_level_is_case_insensitive_and_reaches_app_logging() -> None:
2325

2426
assert custom.log_level == LogLevels.warning
2527
assert custom.api_bootstrapper_config.logging_log_level == logging.WARNING
28+
29+
30+
@pytest.mark.parametrize("pool_settings", [{"db_pool_size": 0}, {"db_max_overflow": -1}])
31+
def test_pool_settings_reject_unbounded_pools(pool_settings: dict[str, int]) -> None:
32+
with pytest.raises(pydantic.ValidationError):
33+
Settings.model_validate(pool_settings)

0 commit comments

Comments
 (0)