VWX.MEDIA
black framed eyeglasses on computer screen

Foto: Oleksandr Chumak / Unsplash (Unsplash License)

Async FastAPI + asyncpg: rzeczywiste pułapki

Pięć błędów, które zatrzymały release'y polskich fintechów.

Admin· 7 maja 2026· 2 min czytania

Wstęp

FastAPI w wersji 0.115 plus asyncpg 0.30 to dziś standardowy stack w polskich fintechach. Wygląda prosto: async def, await, profit. W rzeczywistości pierwsze cztery tygodnie w produkcji to seria nieoczywistych problemów, o których nie pisze się w tutorialach.

Zebrałem pięć pułapek, które realnie zatrzymały release'y projektów, w których brałem udział w ciągu ostatniego roku.

1. Connection pool wycieka pod presją

asyncpg.create_pool(min_size=10, max_size=20) brzmi dobrze, dopóki nie zobaczysz w Datadogu, że pool ma 20 aktywnych połączeń i wszystkie czekają. Najczęstsza przyczyna:

async def get_user(user_id: int):
    async with pool.acquire() as conn:
        user = await conn.fetchrow("SELECT ...", user_id)
        # jakaś logika
        await some_external_api_call()  # HTTP! 2 sekundy!
        return user

Trzymanie połączenia podczas wywołania zewnętrznego API to klasyk. Połączenie z bazą zwalniaj najszybciej jak się da - najlepiej tuż po ostatnim fetch:

async def get_user(user_id: int):
    async with pool.acquire() as conn:
        user = await conn.fetchrow("SELECT ...", user_id)
    await some_external_api_call()
    return user

2. Depends() i async: zagnieżdżone transakcje

FastAPI dependency injection jest kuszące - robisz db: AsyncSession = Depends(get_db) i piszesz "service layer". Problem: jeśli serwis A wywołuje serwis B, a oba dostają tę samą sesję przez Depends, dostajesz jedną długą transakcję.

W trakcie tej transakcji blokujesz wiersze, generujesz long-running query, a Postgres zaczyna logować idle in transaction na 30+ sekund.

Reguła kciuka: dependency wstrzykuje sesję, ale commit/rollback robi > warstwa najwyższa - najlepiej endpoint lub explicit unit_of_work.

3. expire_on_commit=False to nie jest opcja

SQLAlchemy domyślnie po commit unieważnia wszystkie obiekty - przy następnym dostępie do atrybutu robi SELECT. W async świecie ten lazy load rzuca MissingGreenlet, bo jest poza kontekstem async.

SessionLocal = async_sessionmaker(
    engine,
    expire_on_commit=False,
    class_=AsyncSession,
)

Bez tego jednego ustawienia połowa endpointów wybucha przy pierwszej prawdziwej refaktoryzacji.

4. pgbouncer w trybie transaction = goodbye prepared statements

asyncpg domyślnie używa prepared statements. pgbouncer w trybie transaction (czyli ten najczęściej używany na produkcji) nie utrzymuje sesyjnego kontekstu - kolejne zapytanie trafia na inny backend, a prepared statement zniknął.

Rozwiązanie:

engine = create_async_engine(
    DATABASE_URL,
    connect_args={"statement_cache_size": 0},
    pool_pre_ping=True,
)

Tracisz mikro-optymalizację, zyskujesz to, że aplikacja w ogóle działa pod pgbouncerem.

5. Tasks po return - Background Tasks vs prawdziwe queue

@app.post("/email")
async def send(bg: BackgroundTasks):
    bg.add_task(send_marketing_email, ...)
    return {"ok": True}

To wygląda jak kolejka. To nie jest kolejka. Worker uvicorna nadal musi wykonać tę funkcję - jeśli proces dostanie SIGTERM (deploy!), zadanie przepada bez śladu. Do prawdziwych jobów: arq, Dramatiq albo Celery.

Bonus: profiling

pip install py-spy
sudo py-spy top --pid $(pgrep -f uvicorn | head -1)

W asynchronicznym świecie cProfile kłamie - py-spy z flagą --threads pokazuje co naprawdę dzieje się w event loopie.

Podsumowanie

  • pool acquire trzymaj tak krótko jak się da
  • expire_on_commit=False to nie opcja, to wymaganie
  • statement_cache_size=0 jeśli używasz pgbouncer transaction
  • BackgroundTasks to nie kolejka

Każdy z tych punktów zapłaciliśmy incydentem na produkcji. Mam nadzieję, że wam się to oszczędzi.

// udostępnij

Async FastAPI + asyncpg: rzeczywiste pułapki - VWX.MEDIA