Опирается на правила:
R-RES-ISO-1…R-RES-ISO-3иR-RES-ISO-X1…R-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 и timeout | R-RES-ISO-X2 | Явные httpx.Limits и httpx.Timeout в каждом клиенте |
| Разные имена для CB / семафора / клиента одной системы | R-RES-ISO-3 | Единое имя sber / receipt для всех компонентов |
sum(max_connections) всех систем > пул БД | R-RES-ISO-2 | sum ≤ db_pool / 2 |
max_connections < max_concurrent (нет запаса на keep-alive) | R-RES-ISO-2 | max_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-settingsper-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: где что и почему.