Опирается на правила:
R-RES-WHERE-1…R-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-relay — task-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у undiciAgent— гарантия, что один 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-метода без outbound | R-RES-WHERE-X1 | Не нужно |
in-memory retry() для долгих отказов (>30s), пережить рестарт | R-RES-WHERE-3 | Task-queue с PG-таблицей |
@nestjs/throttler на каждом контроллере вместо API Gateway | R-RES-WHERE-4 | Централизованный rate-limit на edge |
| Outbound без CB и bulkhead | R-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.