Опирается на правила: R-RES-WHERE-1R-RES-WHERE-4 и R-RES-WHERE-X1 из Resilience Rules → раздел 1. Где какая защита.

Важно знать

  • Outbound HTTP к внешним системам (платежи, фискализация, страхование) — полный набор: timeout + circuitBreaker + bulkhead + опционально retry. Без CB первый «slow burn» внешней системы копит pending-промисы и выедает event loop.
  • Internal service-to-service (вызовы между нашими микросервисами) — timeout + circuitBreaker. bulkhead — по необходимости, если сервис тяжёлый или на горячем пути.
  • Schedulers и outbox-relaytask-queue через PG-таблицу, не in-memory policy. cockatiel ловит транзиенты <5s; task-queue — долгие отказы (>30s) и переживает рестарт процесса.
  • Inbound REST — rate limit на edge (API Gateway). @nestjs/throttler — только если gateway недоступен.
  • Локальный код (репозиторий, TypeORM/Knex, in-memory вычисления) — никаких policy. Нет транзиентов «иногда работает, иногда нет»: любой сбой здесь — реальная ошибка, не отказ среды.
  • cockatiel-policy — singleton в DI, не пересоздаётся на каждый вызов; wrap(retry, circuitBreaker, bulkhead, timeout) — порядок важен.

cockatiel — это не «навесить везде на всякий случай». Каждой группе вызовов соответствует своя категория защиты; навешивание не туда вредит больше, чем помогает.

Outbound HTTP к внешним системам — полный набор

R-RES-WHERE-1: любой вызов к внешней системе защищён полным набором.

import { Injectable } from '@nestjs/common';
import { wrap, retry, circuitBreaker, bulkhead, timeout,
         ExponentialBackoff, CountBreaker, BrokenCircuitError,
         TimeoutStrategy, handleType } from 'cockatiel';
import { Agent } from 'undici';

import { PaymentPort, RegisterCommand, RegisterResult } from '../../core/payment.port';
import { SberTransientError } from './sber-transient.error';
import { PaymentPortError } from '../../core/payment-port.error';
import { toRegisterRequest, toRegisterResult } from './sber.mapper';
import { SBER_CLIENT } from './sber.tokens';

@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) {}

  async register(cmd: RegisterCommand): Promise<RegisterResult> {
    try {
      const resp = await this.policy.execute(() =>
        this.client.request({ path: '/register', method: 'POST', body: JSON.stringify(toRegisterRequest(cmd)) }),
      );
      return toRegisterResult(await resp.body.json());
    } catch (e) {
      if (e instanceof BrokenCircuitError) throw PaymentPortError.systemUnavailable('sber', e);
      throw PaymentPortError.from(e);
    }
  }
}

Что даёт каждый слой:

  • timeout() (cockatiel) + connectTimeout/headersTimeout/bodyTimeout у undici Agent — гарантия, что один call не зависнет навсегда. Иерархия connect < headers ≤ body < total.
  • circuitBreaker() — fast-fail когда система явно деградирует: после накопленного числа ошибок CB открывается, следующие вызовы падают мгновенно без обращения к внешней системе.
  • bulkhead() — ограничение одновременных вызовов. Работает в текущем async-контексте (AsyncLocalStorage с trace/MDC не теряется). Semaphore-семантика без отдельных потоков.
  • retry() — повтор только при идемпотентности: read-метод или команда с Idempotency-Key.

Без CB один медленный провайдер накапливает pending-промисы, занимает maxSockets undici Agent-а и при достаточной нагрузке останавливает весь сервис. С CB на следующем вызове после порога открытия — немедленный BrokenCircuitError.

Internal service-to-service — timeout + CB

R-RES-WHERE-2: вызовы между нашими собственными микросервисами тоже защищаются, но меньшим набором.

@Injectable()
export class CustomerAdapter implements CustomerPort {
  private readonly policy = wrap(
    circuitBreaker(handleAll, {
      halfOpenAfter: 15_000,
      breaker: new CountBreaker({ threshold: 0.5, size: 20 }),
    }),
    timeout(3_000, TimeoutStrategy.Aggressive),
  );

  async findCustomer(id: CustomerId): Promise<CustomerView> {
    try {
      const resp = await this.policy.execute(() =>
        this.client.request({ path: `/customers/${id.value}`, method: 'GET' }),
      );
      return toCustomerView(await resp.body.json());
    } catch (e) {
      if (e instanceof BrokenCircuitError) throw CustomerPortError.systemUnavailable('customer-service', e);
      throw CustomerPortError.from(e);
    }
  }
}

Почему меньше:

  • Внутренние сервисы под нашим контролем — SLA лучше, retry-семантика прозрачна.
  • CB останавливает каскад при деградации одного из сервисов.
  • bulkhead нужен только если вызов идёт из горячего пути или может затопить connection pool.
  • Retry между нашими сервисами — осторожнее, чем на внешний API: дублирование write без Idempotency-Key опасно.

Schedulers и outbox-relay — task-queue, не cockatiel

R-RES-WHERE-3: для scheduled-работ (cron, outbox-relay, polling-task) cockatiel-policy не подходит.

Почему:

  • cockatiel живёт в памяти процесса. Рестарт Node — потеря CB-state и retry-счётчиков.
  • retry() отрабатывает в рамках одного execute: in-memory backoff до нескольких секунд. Для отказа на минуты этого недостаточно.
  • maxAttempts: 50 с waitDuration = 60s означает 50 минут блокировки promise-цепочки — это не retry, это await sleep(50min) в другой форме.

Task-queue решает проблему через персистентную таблицу с next_attempt_at:

CREATE TABLE order_confirmation_task (
    task_id          BIGINT PRIMARY KEY,
    order_id         BIGINT NOT NULL REFERENCES orders(order_id),
    status           TEXT NOT NULL CHECK (status IN ('PENDING','IN_PROGRESS','COMPLETED','FAILED')),
    retry_count      INTEGER NOT NULL DEFAULT 0,
    next_attempt_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_error       TEXT,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_oct_due ON order_confirmation_task (status, next_attempt_at)
    WHERE status IN ('PENDING', 'IN_PROGRESS');
@Injectable()
export class OrderConfirmationPoller {
  constructor(
    private readonly taskRepo: OrderConfirmationTaskRepository,
    private readonly adapter: SberAdapter,
  ) {}

  @Interval(5_000)
  async processPending(): Promise<void> {
    const tasks = await this.taskRepo.findDue(50);   // FOR UPDATE SKIP LOCKED
    for (const task of tasks) {
      try {
        await this.adapter.confirmOrder(task.orderId);
        await this.taskRepo.markCompleted(task.taskId);
      } catch (e) {
        await this.taskRepo.scheduleRetry(task.taskId, String(e), nextBackoff(task.retryCount));
      }
    }
  }
}

Подробно — в Async и polling.

Inbound REST — rate limit на edge

R-RES-WHERE-4: защита нашего REST API от перегрузки клиентами — это rate limit, и он живёт на API Gateway (Kong, Nginx, Istio), не в каждом сервисе.

Почему на gateway:

  • Единая точка контроля для всех сервисов.
  • Защита до того, как запрос попал в Node-процесс — экономия CPU и памяти event loop.
  • Per-client лимиты по API-key / IP — gateway это умеет, application-код — нет.

@nestjs/throttler в коде допустим только в сценариях, когда gateway недоступен:

@Controller('products')
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 60, ttl: 60_000 } })
export class ProductController {
  @Get()
  async list(): Promise<ProductListResponse> { /* ... */ }
}

Это workaround, не архитектурное решение: при горизонтальном масштабировании каждый Pod считает лимиты независимо — суммарный RPS кратен числу Pod'ов.

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

АнтипаттернПравилоЧто взамен
circuitBreaker()/retry() на репозитории, TypeORM-вызове, in-memory методеR-RES-WHERE-X1Не нужно — нет транзиентов
cockatiel-policy вокруг @Service-метода без outboundR-RES-WHERE-X1Не нужно
in-memory retry() для долгих отказов (>30s), пережить рестартR-RES-WHERE-3Task-queue с PG-таблицей
@nestjs/throttler на каждом контроллере вместо API GatewayR-RES-WHERE-4Централизованный rate-limit на edge
Outbound без CB и bulkheadR-RES-WHERE-1Полный набор обязателен
wrap(...) с литеральными числами вместо конфигаR-RES-CFG-X1Конфиг через env + class-validator

Куда дальше

  • Per-system isolation — отдельный undici Agent и cockatiel-policy на каждую систему.
  • Timeouts — иерархия connectTimeout/headersTimeout/bodyTimeout + timeout()-policy.
  • Circuit Breaker — CountBreaker, halfOpenAfter, маппинг BrokenCircuitError → port-исключение.
  • Retry — когда повторять (и когда нельзя): ExponentialBackoff, handleType, maxAttempts.
  • Bulkhead — semaphore-семантика в event loop: maxConcurrent, queueLimit.
  • Async и polling — task-queue через @Interval-poller для долгих отказов.
  • Fallback — когда деградация допустима и как не проглотить ошибку.
  • Health checks — @nestjs/terminus с TTL-кешем probe-результата.
  • Observability — prom-client метрики, OTel-атрибуты, WARN на CB-переходах.
  • Конфигурация — декларативный конфиг через env + class-validator, per-system override.
  • OpenAPI generator binding — openapi-typescript + mapper DTO → domain.