Опирается на правила: R-RES-CB-1R-RES-CB-6 и R-RES-CB-X1R-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.5 CB откроется после 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 и ReceiptAdapterR-RES-CB-X3Per-system singleton
Самописный CB на try/catch + счётчикR-RES-CB-X2circuitBreaker() из cockatiel
CB на репозитории / TypeORM-вызовеR-RES-CB-X1Только на outbound HTTP out-adapter
Time-based окно (нет в CountBreaker напрямую, но через opossum rollingCountTimeout)R-RES-CB-2CountBreaker
BrokenCircuitError пробрасывается в handler без маппингаR-RES-CB-6Port-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.