Skip to content

Commit 8017334

Browse files
authored
feat: size Granian to the pool and return 503 on pool timeouts (#100)
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 9647751 commit 8017334

9 files changed

Lines changed: 99 additions & 6 deletions

File tree

‎AGENTS.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f
3535
2. Builds a `FastAPIBootstrapper` from `settings.api_bootstrapper_config`, injecting the SQLAlchemy OpenTelemetry instrumentor.
3636
3. `modern_di_fastapi.setup_di(app, container)` wires DI scopes onto the FastAPI app.
3737
4. Includes one `ROUTER` per resource (`app.api.decks.ROUTER`, `app.api.cards.ROUTER`) under `/api` via `include_routers`. Add a new resource by creating `app/api/<name>.py` with its own `APIRouter`-based `ROUTER` and registering it there.
38-
5. Registers `DuplicateKeyError` → 422 handler from `app/exceptions.py`.
38+
5. Registers handlers from `app/exceptions.py`: `DuplicateKeyError` → 422, `NotFoundError` and `ForeignKeyError` → 404, and `sqlalchemy.exc.TimeoutError` and `RepositoryError` → `database_unavailable_handler`, which returns 503 for a pool timeout and re-raises anything else.
3939

4040
### DI scopes (modern-di)
4141

@@ -55,6 +55,10 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f
5555

5656
Endpoints inject a use case with `FromDI(UseCase)` from `modern_di_fastapi`, call it, and convert the result to a schema. They never touch repositories or the session. Add new providers to the matching group rather than constructing services manually in routes.
5757

58+
### Pool and server sizing
59+
60+
`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 re-raises anything not caused by a pool timeout. `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.
61+
5862
### Use cases and lost connections
5963

6064
`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:
@@ -91,7 +95,7 @@ class ListDecksUseCase:
9195

9296
### Settings
9397

94-
`app/settings.py` — `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger.
98+
`app/settings.py` — `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, `APP_WORKERS`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger.
9599

96100
### Tests
97101

‎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,9 +3,10 @@
33

44
import modern_di
55
import modern_di_fastapi
6-
from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError
6+
from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError, RepositoryError
77
from lite_bootstrap import FastAPIBootstrapper
88
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
9+
from sqlalchemy import exc as sa_exc
910

1011
from app import exceptions, ioc
1112
from app.api import cards, decks
@@ -43,4 +44,6 @@ def build_app() -> fastapi.FastAPI:
4344
ForeignKeyError,
4445
exceptions.not_found_error_handler, # ty: ignore[invalid-argument-type]
4546
)
47+
app.add_exception_handler(sa_exc.TimeoutError, exceptions.database_unavailable_handler)
48+
app.add_exception_handler(RepositoryError, exceptions.database_unavailable_handler)
4649
return app

‎app/exceptions.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import TYPE_CHECKING
22

33
from fastapi.responses import JSONResponse
4+
from sqlalchemy import exc as sa_exc
45
from starlette import status
56

67

@@ -21,3 +22,12 @@ async def not_found_error_handler(_: Request, __: NotFoundError | ForeignKeyErro
2122
status_code=status.HTTP_404_NOT_FOUND,
2223
content={"detail": "Not found"},
2324
)
25+
26+
27+
async def database_unavailable_handler(_: Request, exc: Exception) -> JSONResponse:
28+
if not isinstance(exc, sa_exc.TimeoutError) and not isinstance(exc.__cause__, sa_exc.TimeoutError):
29+
raise exc
30+
return JSONResponse(
31+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
32+
content={"detail": "Database unavailable"},
33+
)

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

‎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)