Опирается на правила:
R-RES-CB-1…R-RES-CB-6иR-RES-CB-X1…R-RES-CB-X3из Resilience Style Guide → раздел 4. Circuit Breaker.
Важно знать
- CB-policy (
circuitBreaker(handleAll, {...})) создаётся один раз в DI и живёт как singleton — не пересоздавать на каждый вызов.circuitBreakerоборачивает public-метод out-adapter (SberAdapter). Не сгенерированный клиент, не handler, не репозиторий.- Тип breaker —
CountBreaker({ threshold: 0.5, size: 50 }), не time-based окно.- Порог failure rate:
0.5(50%) — дефолт;0.3(30%) — для критичных систем (платежи).halfOpenAfter: 30_000— пауза в open-state. Half-open: ~3 пробных вызова; все успешны → closed, иначе снова open.- Slow-call ловится отдельной
timeout()-policy с порогом ≈readTimeout / 2; ошибки таймаута считаются CB как failures.- При open-state cockatiel бросает
BrokenCircuitError. Адаптер маппит в port-specific exception, exception filter — в503/409.
Circuit Breaker — выключатель: когда внешняя система явно не отвечает, новые вызовы перестают туда идти и сразу падают. Это предотвращает накопление pending-промисов и не давит дополнительной нагрузкой на восстанавливающуюся систему. В Node cockatiel даёт готовую реализацию с событиями для метрик — изобретать на try/catch-счётчиках не нужно.
Policy-композиция на public-методе out-adapter
R-RES-CB-1: policy создаётся один раз через wrap(...) и живёт как singleton в DI. Все вызовы findPayment/register проходят через один и тот же state-machine.
import {
circuitBreaker,
bulkhead,
retry,
timeout,
wrap,
handleType,
handleAll,
CountBreaker,
ExponentialBackoff,
TimeoutStrategy,
BrokenCircuitError,
} from 'cockatiel';
import { Agent } from 'undici';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SberAdapter implements PaymentPort {
private readonly policy = wrap(
retry(handleType(SberTransientError), {
maxAttempts: 3,
backoff: new ExponentialBackoff(),
}),
circuitBreaker(handleAll, {
halfOpenAfter: 30_000,
breaker: new CountBreaker({ threshold: 0.5, size: 50 }),
}),
bulkhead(8),
timeout(5_000, TimeoutStrategy.Aggressive),
);
constructor(
@Inject(SBER_CLIENT) private readonly client: Agent,
private readonly mapper: SberMapper,
) {}
async findPayment(ref: PaymentRef): Promise<Payment> {
try {
const resp = await this.policy.execute(ctx =>
this.client.request({
origin: this.baseUrl,
path: `/payments/${ref.id}`,
method: 'GET',
signal: ctx.signal,
}),
);
return this.mapper.toDomain(await resp.body.json());
} catch (e) {
if (e instanceof BrokenCircuitError) {
throw PaymentPortError.systemUnavailable('sber', e);
}
throw PaymentPortError.from(e);
}
}
}
Почему именно так:
- Не на сгенерированном клиенте (
SberApi) —openapi-typescript/openapi-generator перегенерирует файлы на build-шаге; policy-обёртки потеряются (R-RES-OAS-X1). - Не в
executeCall<T>-helper'е со строкой-именем системы — теряется compile-time связь policy ↔ система, опечатка'sbr'всплывёт на runtime (R-RES-OAS-X2). - Не на handler'е (
ConfirmOrderHandler) — handler оркеструет UC, out-adapter — граница защиты. - Не на репозитории — локальные операции не имеют транзиентов (
R-RES-CB-X1,R-RES-WHERE-X1).
CountBreaker — count-based окно
R-RES-CB-2: скользящее окно по числу вызовов, не по времени.
new CountBreaker({ threshold: 0.5, size: 50 })
// threshold — доля failures для открытия (0.5 = 50%)
// size — размер окна (последние 50 вызовов)
// minimumNumberOfCalls по умолчанию ~10 — не открывается пока нет достаточной статистики
Почему count-based, а не time-based:
- Outbound к внешним системам идёт неравномерно: всплески после write-операций, паузы в промежутках. Time-based окно (например, 30s) ничего не показывает в паузе.
- «50% из последних 50» — чёткий критерий: 25 failures → open. «50% за 30 секунд» зависит от количества вызовов в этот отрезок.
Threshold — 30% для платёжных систем
R-RES-CB-3: для Sber и других платёжных систем — threshold: 0.3 вместо дефолтных 0.5.
Обоснование:
- При
0.5CB откроется после 25 failures из 50. На реально лежачей Sber (readTimeout 5s) это ~2.5 минуты ожидания пользователей. - При
0.3— 15 failures из 50: примерно вдвое быстрее fast-fail и разгрузка восстанавливающейся системы. - Компромисс: более низкий порог чаще даёт ложные срабатывания на transient 5xx. Для money — лучше быстрый fast-fail с постановкой в task-queue, чем затяжное давление.
// Платёжный адаптер — нижний порог
breaker: new CountBreaker({ threshold: 0.3, size: 50 })
// Не-критичная система — дефолт
breaker: new CountBreaker({ threshold: 0.5, size: 50 })
Half-open и пробные вызовы
R-RES-CB-4: после halfOpenAfter: 30_000 (30s в open) cockatiel переходит в half-open. CountBreaker внутренне пропускает несколько пробных вызовов — все успешны → closed; хотя бы один fail → снова open ещё на 30s.
circuitBreaker(handleAll, {
halfOpenAfter: 30_000,
breaker: new CountBreaker({ threshold: 0.5, size: 50 }),
})
Почему не 1 и не 10 пробных вызовов:
- 1 — слишком хрупко: один transient 5xx во время восстановления отбрасывает обратно в open.
- 10 — избыточная нагрузка при восстановлении: если система ещё нестабильна, 10 одновременных вызовов могут подтопить её повторно.
Slow-call через timeout-policy
R-RES-CB-5: slow-call cockatiel сам не ловит как отдельную категорию — для этого timeout()-policy с порогом ≈ readTimeout / 2 ставится внутри wrap (выполняется первой). Когда вызов превышает этот порог и TimeoutStrategy.Aggressive отменяет его через AbortSignal, cockatiel засчитывает исключение как failure — CB накапливает счётчик.
private readonly policy = wrap(
retry(...),
circuitBreaker(handleAll, { halfOpenAfter: 30_000, breaker: new CountBreaker({ threshold: 0.5, size: 50 }) }),
bulkhead(8),
timeout(2_500, TimeoutStrategy.Aggressive), // readTimeout = 5_000 → /2
);
Что это даёт: без отдельного timeout CB ловит только явные ошибки сети/5xx. Если система отвечает за 4s вместо 200ms, CB закрыт — пользователи ждут. С timeout-policy медленные вызовы превращаются в TaskCancelledError, который handleAll считает failure.
Маппинг BrokenCircuitError
R-RES-CB-6: при open-state cockatiel бросает BrokenCircuitError (opossum аналогично кидает OpenCircuitError). Адаптер не пропускает его наружу как есть — маппит в port-specific исключение, exception filter — в HTTP-статус по UC.
// Адаптер
} catch (e) {
if (e instanceof BrokenCircuitError) {
throw PaymentPortError.systemUnavailable('sber', e);
}
throw PaymentPortError.from(e);
}
// Exception filter в bootstrap/
@Catch(PaymentPortError)
export class PaymentPortExceptionFilter implements ExceptionFilter {
catch(error: PaymentPortError, host: ArgumentsHost) {
const resp = host.switchToHttp().getResponse<Response>();
if (error.code === 'SYSTEM_UNAVAILABLE') {
resp.status(503).json({ error: 'system_unavailable', message: 'Платёжная система временно недоступна' });
}
}
}
Почему port-specific, а не BrokenCircuitError напрямую в filter: handler и filter — слои приложения, они не должны зависеть от деталей transport-библиотеки. Замена cockatiel на opossum не затронет handler и filter.
Провайдер policy в DI
Policy — singleton, создаётся при инициализации модуля:
// payment.module.ts
@Module({
providers: [
{
provide: SBER_CLIENT,
useFactory: (cfg: SberClientConfig) =>
new Agent({
connections: cfg.maxConnections,
connectTimeout: cfg.connectTimeoutMs,
headersTimeout: cfg.readTimeoutMs,
bodyTimeout: cfg.readTimeoutMs,
}),
inject: [SberClientConfig],
},
SberAdapter,
],
})
export class PaymentModule {}
// sber-adapter.ts — policy как поле класса, инициализируется один раз
@Injectable()
export class SberAdapter implements PaymentPort {
private readonly policy = wrap(
retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() }),
circuitBreaker(handleAll, {
halfOpenAfter: 30_000,
breaker: new CountBreaker({ threshold: 0.3, size: 50 }),
}),
bulkhead(8),
timeout(5_000, TimeoutStrategy.Aggressive),
);
// ...
}
R-RES-CB-X3: policy создаётся per-system. SberAdapter и ReceiptAdapter имеют каждый свою policy с независимым CB state. Если Sber начнёт отвечать 5xx, его CB откроется — Receipt продолжит работать.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Policy на сгенерированном клиенте (SberApi) | R-RES-OAS-X1 | На public-методе out-adapter (SberAdapter) |
CB в executeCall<T>-helper'е со строкой-именем системы | R-RES-OAS-X2 | Отдельная policy per-adapter, имя — через DI-токен |
Одна shared policy на SberAdapter и ReceiptAdapter | R-RES-CB-X3 | Per-system singleton |
Самописный CB на try/catch + счётчик | R-RES-CB-X2 | circuitBreaker() из cockatiel |
| CB на репозитории / TypeORM-вызове | R-RES-CB-X1 | Только на outbound HTTP out-adapter |
Time-based окно (нет в CountBreaker напрямую, но через opossum rollingCountTimeout) | R-RES-CB-2 | CountBreaker |
BrokenCircuitError пробрасывается в handler без маппинга | R-RES-CB-6 | Port-specific exception → exception filter |
Куда дальше
- Per-system isolation — отдельный
Agent/axios-инстанс и policy-singleton на каждую систему. - Timeouts — иерархия
connectTimeout < headersTimeout < bodyTimeout+timeout()-policy. - Bulkhead —
bulkhead(maxConcurrent)в composited policy, sizing < connections. - Retry —
retry()только при идемпотентности,ExponentialBackoff, не на 4xx. - Fallback — что делать при
BrokenCircuitError, когда допустим и когда нет. - Конфигурация — типизированный
SberClientConfigчерез zod/class-validator. - Health checks —
@nestjs/terminusиндикатор с TTL-кешем. - Observability —
onBreak/onResetсобытия вprom-client, OTel-атрибуты. - Async и polling — task-queue вместо
setTimeout-цикла. - Где какая защита — почему CB не нужен на репозитории и inbound.
- OpenAPI generator binding — где именно ставить policy при codegen.