Опирается на правила:
R-RES-OAS-1…R-RES-OAS-4иR-RES-OAS-X1…R-RES-OAS-X3из Resilience Rules → раздел 9. Связка с OpenAPI generator.
Важно знать
- CB / retry / bulkhead — на public-методе out-adapter класса, который оборачивает вызов сгенерированного клиента. Не на сгенерированном клиенте, не в helper-функции.
- Сгенерированный клиент перегенерируется из YAML при каждой сборке — любые правки в нём пропадут.
- Инструмент генерации —
openapi-python-client(httpx-native) илиopenapi-generatorс python-target; спека внешнего API вadapters/out/<system>/openapi/<system>.yaml.- Сгенерированный код не коммитится; добавляется в
.gitignore.- Mapper обязателен между сгенерированными DTO и domain-типами из
core/. Сгенерированные DTO — транспорт, не доменные типы.- Port-метод возвращает domain-тип (
PaymentRef,OrderStatus), а не DTO внешней системы.- Все resilience-параметры — через
pydantic-settings(<System>ClientSettings), не хардкодом.
OpenAPI-first для outbound: внешний API описан YAML-ом, из него генерируется httpx-клиент, вокруг него пишется адаптер. Ключевой вопрос — где именно крепить защиту, и почему именно там.
Обёртки на public-методе out-adapter
R-RES-OAS-1: asyncio.Semaphore (bulkhead), CB (purgatory/aiobreaker), tenacity (retry) — на нашем adapter-методе, оборачивающем сгенерированный клиент.
# adapters/out/sber/generated/ ← не модифицируем (перегенерируется)
# openapi-python-client создаёт: SberClient, RegisterRequest, RegisterResponse, GetOrderStatusResponse...
# core/payment/port/out/payment_port.py — domain port:
from abc import ABC, abstractmethod
from core.payment.domain import Order, PaymentRef, OrderStatus, OrderId
class PaymentPort(ABC):
@abstractmethod
async def register(self, order: Order) -> PaymentRef: ...
@abstractmethod
async def get_status(self, order_id: OrderId) -> OrderStatus: ...
# adapters/out/sber/sber_adapter.py — наш adapter:
import asyncio
import tenacity
from purgatory import CircuitBreakerError
from adapters.out.sber.generated import SberClient
from adapters.out.sber.mapper import to_sber_request, to_domain_ref, to_domain_status
from adapters.out.sber.settings import SberClientSettings
from core.payment.domain import Order, PaymentRef, OrderStatus, OrderId
from core.payment.port.out.payment_port import PaymentPort
from core.payment.exception import PaymentSystemUnavailable
class SberAdapter(PaymentPort):
def __init__(
self,
client: SberClient,
breaker,
semaphore: asyncio.Semaphore,
settings: SberClientSettings,
) -> None:
self._client = client
self._breaker = breaker
self._sem = semaphore
self._settings = settings
async def register(self, order: Order) -> PaymentRef:
async with self._sem:
try:
async with self._breaker:
async with asyncio.timeout(self._settings.total_timeout):
req = to_sber_request(order)
resp = await self._client.register.asyncio(body=req)
return to_domain_ref(resp)
except CircuitBreakerError as exc:
raise PaymentSystemUnavailable("sber") from exc
@tenacity.retry(
wait=tenacity.wait_exponential(multiplier=0.5, min=0.5, max=4),
stop=tenacity.stop_after_attempt(3),
retry=tenacity.retry_if_exception_type((TimeoutError, httpx.ConnectError)),
reraise=True,
)
async def get_status(self, order_id: OrderId) -> OrderStatus:
async with self._sem:
async with self._breaker:
async with asyncio.timeout(self._settings.total_timeout):
resp = await self._client.get_order_status.asyncio(
order_id=order_id.value
)
return to_domain_status(resp)
Почему именно здесь:
- Сгенерированный
SberClientперегенерируется при каждомmake generateили CI-шаге — любые декораторы/обёртки на его методах исчезнут. - Helper-функция с именем системы строкой (
execute_with_cb("sber", ...)) теряет compile-time проверку: опечатка в строке обнаружится только в рантайме. SberAdapter.register— публичная граница portPaymentPort. Именно на ней имеет смысл мерить «вызов к Sber» как бизнес-операцию.
Генерация клиента из OpenAPI-спеки
R-RES-OAS-2: для нового кода клиент генерируется из OpenAPI-спеки внешней системы.
openapi-python-client создаёт httpx-native async-клиент, не требующий дополнительной обвязки:
# pyproject.toml — dev-зависимость
[tool.poetry.dev-dependencies]
openapi-python-client = ">=0.21"
# Makefile
generate-sber:
openapi-python-client generate \
--path adapters/out/sber/openapi/sber.yaml \
--output adapters/out/sber/generated \
--overwrite
# adapters/out/sber/openapi/sber.yaml ← спека внешней системы, коммитится
# adapters/out/sber/generated/ ← в .gitignore, регенерируется
Что даёт генерация:
- Типизированный async-клиент на httpx — методы возвращают Pydantic-модели, не
dict. - Pydantic-валидация ответа внешней системы на входе, до маппинга в domain. Breaking change в API внешней системы ловится на этапе валидации, не на обращении к полю.
- Обновление спеки через PR — diff в YAML видно в review; сгенерированный клиент не коммитится, и diff его никого не беспокоит.
Альтернатива — openapi-generator с python-target (тоже httpx-native). Выбор инструмента — по команде; принципы одинаковы.
Расположение спеки и codegen
R-RES-OAS-3: OpenAPI-спека внешнего API — в adapters/out/<system>/openapi/<system>.yaml. Сгенерированный код — в .gitignore.
adapters/out/
├── sber/
│ ├── openapi/
│ │ └── sber.yaml # ← спека внешнего API, коммитится
│ ├── generated/ # ← в .gitignore, не коммитится
│ │ ├── __init__.py
│ │ ├── client.py
│ │ └── models/
│ │ ├── register_request.py
│ │ └── register_response.py
│ ├── sber_adapter.py # ← наш адаптер, оборачивает generated
│ ├── mapper.py # ← DTO → domain
│ └── settings.py # ← pydantic-settings
├── receipt/
│ └── ...
Важные детали:
sber.yaml— копия (или форк) внешнего OpenAPI. Если внешняя система не публикует спеку — пишется вручную по документации.- Версионирование — спека коммитится,
generated/— нет. PR со spec-update показывает YAML-diff, из которого ясно, что изменилось в API. - CI — шаг
make generateзапускается до сборки; если сгенерированный код изменился — сборка падает с подсказкой сделать коммит спеки.
Mapper между сгенерированными DTO и domain
R-RES-OAS-4: между сгенерированным клиентом и port из core/ — обязательный mapper.
# core/payment/domain.py — domain-типы (не зависят от внешней системы):
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
@dataclass(frozen=True)
class OrderId:
value: str
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
@dataclass(frozen=True)
class PaymentRef:
external_id: str
status: "PaymentStatus"
class PaymentStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
DECLINED = "declined"
# adapters/out/sber/mapper.py — DTO → domain:
from adapters.out.sber.generated.models import (
RegisterRequest,
RegisterResponse,
SberStatus,
)
from core.payment.domain import Order, PaymentRef, PaymentStatus, Money
def to_sber_request(order: Order) -> RegisterRequest:
return RegisterRequest(
order_id=order.id.value,
amount=float(order.total.amount),
currency=order.total.currency,
idempotency_key=order.idempotency_key,
)
def to_domain_ref(resp: RegisterResponse) -> PaymentRef:
return PaymentRef(
external_id=resp.sber_order_id,
status=_map_status(resp.status),
)
def _map_status(status: SberStatus) -> PaymentStatus:
match status:
case SberStatus.CONFIRMED:
return PaymentStatus.CONFIRMED
case SberStatus.PENDING:
return PaymentStatus.PENDING
case SberStatus.DECLINED:
return PaymentStatus.DECLINED
case _:
raise ValueError(f"Unknown Sber status: {status}")
Зачем mapper:
- Сгенерированные DTO — транспорт. Они меняются вместе с внешним API. Domain — стабильный.
- Изоляция от breaking changes. Внешняя система переименовала поле — меняется один mapper, остальной код не задет.
- Явное соответствие статусов.
SberStatus.PENDING≠PaymentStatus.PENDINGсемантически — mapper делает explicit-перевод с ветвьюcase _на незнакомые значения.
Конфигурация через pydantic-settings
R-RES-CFG-1 / R-RES-OAS-4: параметры клиента — через pydantic-settings, не хардкодом.
# adapters/out/sber/settings.py
from pydantic import Field
from pydantic_settings import BaseSettings
class SberClientSettings(BaseSettings):
base_url: str = Field(alias="client_sber__base_url")
connect_timeout: float = Field(default=2.0, alias="client_sber__connect_timeout")
read_timeout: float = Field(default=10.0, alias="client_sber__read_timeout")
total_timeout: float = Field(default=12.0, alias="client_sber__total_timeout")
max_connections: int = Field(default=10, alias="client_sber__max_connections")
max_keepalive: int = Field(default=8, alias="client_sber__max_keepalive")
max_concurrent: int = Field(default=8, alias="client_sber__max_concurrent")
cb_failure_rate: float = Field(default=0.30, alias="client_sber__cb_failure_rate")
cb_window_size: int = Field(default=50, alias="client_sber__cb_window_size")
model_config = {"env_nested_delimiter": "__"}
# adapters/out/sber/factory.py — сборка адаптера по настройкам:
import asyncio
import httpx
from purgatory import AsyncCircuitBreaker
from adapters.out.sber.generated import AuthenticatedClient
from adapters.out.sber.sber_adapter import SberAdapter
from adapters.out.sber.settings import SberClientSettings
def build_sber_adapter(settings: SberClientSettings) -> SberAdapter:
limits = httpx.Limits(
max_connections=settings.max_connections,
max_keepalive_connections=settings.max_keepalive,
)
timeout = httpx.Timeout(
connect=settings.connect_timeout,
read=settings.read_timeout,
write=settings.read_timeout,
pool=settings.connect_timeout,
)
http_client = httpx.AsyncClient(base_url=settings.base_url, limits=limits, timeout=timeout)
generated_client = AuthenticatedClient(base_url=settings.base_url, httpx_client=http_client)
breaker = AsyncCircuitBreaker(
failure_threshold=settings.cb_failure_rate,
recovery_timeout=30,
name="sber",
)
semaphore = asyncio.Semaphore(settings.max_concurrent)
return SberAdapter(generated_client, breaker, semaphore, settings)
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Декораторы/обёртки на методах сгенерированного клиента | R-RES-OAS-X1 | На public-методе out-adapter |
Helper execute_with_cb("sber", ...) со строковым именем | R-RES-OAS-X2 | CB-инстанс в __init__, применяется на методе адаптера |
PaymentPort.register возвращает RegisterResponse (сгенерированный DTO) | R-RES-OAS-X3 | Domain PaymentRef + mapper |
| Сгенерированный код в git | R-RES-OAS-3 | .gitignore на adapters/out/<system>/generated/ |
httpx.AsyncClient() без limits/timeout в сгенерированном клиенте | R-RES-ISO-X2 | Явные httpx.Limits + httpx.Timeout per-system |
Один AsyncClient на несколько внешних систем | R-RES-ISO-X1 | Отдельный клиент per-system |
Куда дальше
- Per-system isolation — отдельный
AsyncClient+ CB + семафор на каждую систему. - Circuit Breaker —
purgatory/aiobreaker, настройка окна и порогов. - Bulkhead —
asyncio.Semaphoreper-system, sizing < pool. - Retry —
tenacity, exponential backoff, только при идемпотентности. - Timeouts — иерархия
connect < read < total,asyncio.timeout. - Configuration —
pydantic-settings, defaults + per-system override. - Fallback — допустимые случаи, контракт, запреты для money.
- Health checks — TTL-кеш probe, FastAPI
/health/ready. - Observability —
prometheus-client, OTel-spans, state-transition лог. - Async и polling — task-queue вместо
asyncio.sleep-цикла. - Где ставить защиту — outbound vs inbound vs schedulers.