Опирается на правила: R-RES-ISO-1R-RES-ISO-3 и R-RES-ISO-X1R-RES-ISO-X2 из Resilience Style Guide → раздел 2. Per-system isolation.

Важно знать

  • На каждую внешнюю систему — отдельный httpx.AsyncClient с собственными httpx.Limits, asyncio.Semaphore, CircuitBreaker — ни один компонент не shared между системами.
  • Имя системы едино: sber, receipt, insurance — одно и то же имя для клиента, семафора, CB и секции конфига (R-RES-ISO-3).
  • Pool sizing: max_connections ≈ max_concurrent × 1.2 (запас на keep-alive idle). Суммарно по всем системам ≤ пул БД / 2.
  • Изоляция нужна, потому что зависание одной системы не должно блокировать другие: при shared client застрявшие коннекты Sber отъедают слоты у OdnaKassa.
  • httpx.AsyncClient() без явных limits и timeout берёт глобальные дефолты — это всегда настраивается явно (R-RES-ISO-X2).
  • Обёртки (CB, семафор) располагаются на public-методе out-adapter, не на сгенерированном клиенте и не в handler (R-RES-CB-1, R-RES-BH-1).
  • Конфигурация — через pydantic-settings секции client.<system>, не хардкодом (R-RES-CFG-1).

Если у сервиса больше одной внешней зависимости, per-system isolation — первое, что строится. Без неё медленный Sber удерживает asyncio-воркеры, и OdnaKassa начинает тормозить без какой-либо своей проблемы. Раскрытие раздела 2 гайда для Python async-стека.

Отдельный AsyncClient на каждую систему

R-RES-ISO-1: на каждую систему — отдельный httpx.AsyncClient с явными httpx.Limits и httpx.Timeout.

# adapters/out/sber/sber_client.py
import httpx
from app.config import SberClientSettings

def build_sber_client(settings: SberClientSettings) -> httpx.AsyncClient:
    limits = httpx.Limits(
        max_connections=settings.max_connections,
        max_keepalive_connections=settings.max_keepalive_connections,
        keepalive_expiry=300.0,
    )
    timeout = httpx.Timeout(
        connect=settings.connect_timeout,
        read=settings.read_timeout,
        write=settings.write_timeout,
        pool=settings.pool_timeout,
    )
    return httpx.AsyncClient(
        base_url=settings.base_url,
        limits=limits,
        timeout=timeout,
    )
# adapters/out/receipt/receipt_client.py
def build_receipt_client(settings: ReceiptClientSettings) -> httpx.AsyncClient:
    limits = httpx.Limits(
        max_connections=settings.max_connections,
        max_keepalive_connections=settings.max_keepalive_connections,
    )
    timeout = httpx.Timeout(
        connect=settings.connect_timeout,
        read=settings.read_timeout,
        write=settings.write_timeout,
        pool=settings.pool_timeout,
    )
    return httpx.AsyncClient(
        base_url=settings.base_url,
        limits=limits,
        timeout=timeout,
    )

Фабрики регистрируются в DI-контейнере (например, через dependency-injector или FastAPI lifespan):

# app/container.py
from dependency_injector import containers, providers
from adapters.out.sber.sber_client import build_sber_client
from adapters.out.receipt.receipt_client import build_receipt_client

class Container(containers.DeclarativeContainer):
    config = providers.Configuration()

    sber_settings = providers.Singleton(SberClientSettings)
    receipt_settings = providers.Singleton(ReceiptClientSettings)

    sber_client = providers.Resource(
        build_sber_client,
        settings=sber_settings,
    )
    receipt_client = providers.Resource(
        build_receipt_client,
        settings=receipt_settings,
    )

Или через FastAPI lifespan, если контейнер не используется:

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    settings = SberClientSettings()
    app.state.sber_client = build_sber_client(settings)
    settings_r = ReceiptClientSettings()
    app.state.receipt_client = build_receipt_client(settings_r)
    yield
    await app.state.sber_client.aclose()
    await app.state.receipt_client.aclose()

aclose() в блоке завершения lifespan — обязателен: без него при graceful shutdown остаются незакрытые TCP-соединения.

Sizing connection pool

R-RES-ISO-2: pool sizing связывает HTTP-клиент и пул БД на двух уровнях.

Per-system: max_connections ≈ max_concurrent × 1.2. Запас 20% нужен для keep-alive idle соединений: пока asyncio.Semaphore считает вызов завершённым, TCP-соединение ещё может находиться в idle-состоянии внутри пула httpx.

Total: sum(max_connections всех систем) ≤ пул БД / 2. При пиковой нагрузке HTTP-коннекты и DB-коннекты конкурируют за file descriptors. Пропорция 1:1 (половина на HTTP, половина на БД) — консервативная, но рабочая точка для большинства сервисов.

Пример для сервиса с тремя внешними системами:

# config/settings.yaml
database:
  pool_size: 40

client:
  sber:
    max_concurrent: 16
    max_connections: 20       # 16 × 1.25 ≈ 20
    max_keepalive_connections: 16
  receipt:
    max_concurrent: 8
    max_connections: 10
    max_keepalive_connections: 8
  insurance:
    max_concurrent: 6
    max_connections: 8
    max_keepalive_connections: 6
                              # total max_connections: 38 > 40/2 = 20 — нарушение!

38 > 20 — нарушение R-RES-ISO-2. Решения: уменьшить max_concurrent (если пропускная способность позволяет) или увеличить database.pool_size до 80+. Без коррекции на пиковой нагрузке HTTP-слой может «отнять» file descriptors у asyncpg/psycopg.

Корректный вариант при pool_size: 40:

client:
  sber:
    max_concurrent: 8
    max_connections: 10
  receipt:
    max_concurrent: 4
    max_connections: 5
  insurance:
    max_concurrent: 3
    max_connections: 4
                            # total: 19 ≤ 40/2 = 20 — OK

Единое имя для клиента, CB и семафора

R-RES-ISO-3: sber_client, sber_breaker, sber_semaphore — одно имя на всю «единицу изоляции». Конфиг, метрики и логи используют то же имя.

# adapters/out/sber/sber_adapter.py
import asyncio
import httpx
from aiobreaker import CircuitBreaker, CircuitBreakerError
from core.ports.payment_port import PaymentPort, PaymentPortError
from core.domain.order import Order, PaymentRef
from adapters.out.sber.sber_mapper import to_sber_request, to_payment_ref

class SberAdapter(PaymentPort):
    def __init__(
        self,
        client: httpx.AsyncClient,
        breaker: CircuitBreaker,
        semaphore: asyncio.Semaphore,
    ) -> None:
        self._client = client
        self._breaker = breaker
        self._sem = semaphore

    async def register(self, order: Order) -> PaymentRef:
        async with asyncio.timeout(0.1):           # acquire timeout — fail-fast
            await self._sem.acquire()
        try:
            async with self._breaker:
                async with asyncio.timeout(self._settings.total_timeout):
                    resp = await self._client.post(
                        "/register",
                        json=to_sber_request(order),
                    )
                    resp.raise_for_status()
                    return to_payment_ref(resp.json())
        except CircuitBreakerError as exc:
            raise PaymentPortError.system_unavailable("sber") from exc
        finally:
            self._sem.release()
# adapters/out/receipt/receipt_adapter.py
class ReceiptAdapter(ReceiptPort):
    def __init__(
        self,
        client: httpx.AsyncClient,
        breaker: CircuitBreaker,
        semaphore: asyncio.Semaphore,
    ) -> None:
        self._client = client
        self._breaker = breaker
        self._sem = semaphore

Сборка компонентов системы sber под единым именем:

# app/container.py
from aiobreaker import CircuitBreaker

class Container(containers.DeclarativeContainer):
    ...
    sber_breaker = providers.Singleton(
        CircuitBreaker,
        fail_max=5,
        reset_timeout=30,
        name="sber",
    )
    sber_semaphore = providers.Singleton(
        asyncio.Semaphore,
        value=16,
    )
    sber_adapter = providers.Singleton(
        SberAdapter,
        client=sber_client,
        breaker=sber_breaker,
        semaphore=sber_semaphore,
    )

Зачем единое имя:

  • Корреляция в мониторинге. В метриках Prometheus, структурных логах и конфиге одно слово sber — не нужно сопоставлять sber_cb с payment_bulkhead с sber-http.
  • Меньше ошибок при копировании. Новая система insurance — скопировал блок, заменил sber на insurance. Нет шанса нацелить CB на sber, а семафор на ins.
  • Читаемость container.py. Три строки sber_client / sber_breaker / sber_semaphore визуально группируются в единицу изоляции.

Конфигурация через pydantic-settings

R-RES-CFG-1: все параметры клиента — в pydantic-settings, не хардкодом.

# config/settings.py
from pydantic_settings import BaseSettings
from pydantic import Field

class SberClientSettings(BaseSettings):
    base_url: str = Field(alias="CLIENT__SBER__BASE_URL")
    max_concurrent: int = Field(default=16, alias="CLIENT__SBER__MAX_CONCURRENT")
    max_connections: int = Field(default=20, alias="CLIENT__SBER__MAX_CONNECTIONS")
    max_keepalive_connections: int = Field(default=16, alias="CLIENT__SBER__MAX_KEEPALIVE_CONNECTIONS")
    connect_timeout: float = Field(default=2.0, alias="CLIENT__SBER__CONNECT_TIMEOUT")
    read_timeout: float = Field(default=10.0, alias="CLIENT__SBER__READ_TIMEOUT")
    write_timeout: float = Field(default=5.0, alias="CLIENT__SBER__WRITE_TIMEOUT")
    pool_timeout: float = Field(default=3.0, alias="CLIENT__SBER__POOL_TIMEOUT")
    total_timeout: float = Field(default=15.0, alias="CLIENT__SBER__TOTAL_TIMEOUT")

    class Config:
        env_file = ".env"
        env_nested_delimiter = "__"


class ReceiptClientSettings(BaseSettings):
    base_url: str = Field(alias="CLIENT__RECEIPT__BASE_URL")
    max_concurrent: int = Field(default=8, alias="CLIENT__RECEIPT__MAX_CONCURRENT")
    max_connections: int = Field(default=10, alias="CLIENT__RECEIPT__MAX_CONNECTIONS")
    max_keepalive_connections: int = Field(default=8, alias="CLIENT__RECEIPT__MAX_KEEPALIVE_CONNECTIONS")
    connect_timeout: float = Field(default=2.0, alias="CLIENT__RECEIPT__CONNECT_TIMEOUT")
    read_timeout: float = Field(default=8.0, alias="CLIENT__RECEIPT__READ_TIMEOUT")
    write_timeout: float = Field(default=5.0, alias="CLIENT__RECEIPT__WRITE_TIMEOUT")
    pool_timeout: float = Field(default=3.0, alias="CLIENT__RECEIPT__POOL_TIMEOUT")
    total_timeout: float = Field(default=12.0, alias="CLIENT__RECEIPT__TOTAL_TIMEOUT")

    class Config:
        env_file = ".env"
        env_nested_delimiter = "__"

Секции CLIENT__SBER__* и CLIENT__RECEIPT__* в .env или в переменных окружения — изменение параметров без перекомпиляции. Именование следует R-RES-ISO-3: имя системы (SBER, RECEIPT) — часть имени переменной.

Что запрещено

АнтипаттернПравилоЧто взамен
Один shared AsyncClient на несколько системR-RES-ISO-X1Отдельный AsyncClient на каждую систему
httpx.AsyncClient() без явных limits и timeoutR-RES-ISO-X2Явные httpx.Limits и httpx.Timeout в каждом клиенте
Разные имена для CB / семафора / клиента одной системыR-RES-ISO-3Единое имя sber / receipt для всех компонентов
sum(max_connections) всех систем > пул БДR-RES-ISO-2sum ≤ db_pool / 2
max_connections < max_concurrent (нет запаса на keep-alive)R-RES-ISO-2max_connections ≈ max_concurrent × 1.2
CB / семафор внутри сгенерированного клиентаR-RES-OAS-X1Обёртки на public-методе out-adapter

Куда дальше

  • Timeouts — httpx.Timeout и asyncio.timeout: иерархия connect < read < total.
  • Circuit Breaker — per-system CB: count-based окно, порог, open/half-open, маппинг в port-исключение.
  • Bulkhead — asyncio.Semaphore: sizing, fail-fast acquire, совместная работа с CB.
  • Конфигурация — pydantic-settings per-system, дефолты и переопределения.
  • Retry — tenacity: экспоненциальный backoff, идемпотентность, граница с task-queue.
  • Fallback — допустимые случаи деградации, запреты для money-операций.
  • Health checks — TTL-кеш probe, readiness vs liveness.
  • Observability — prometheus-client, OTel-спаны, state-transition WARN.
  • Async и polling — asyncio.sleep-цикл vs task-queue.
  • OpenAPI generator binding — где размещать обёртки при generated-клиенте.
  • Где какая защита — inbound, outbound, schedulers: где что и почему.