Опирается на правила: R-RES-CFG-1, R-RES-CFG-2, R-RES-CFG-3, R-RES-CFG-X1, R-RES-ISO-3 из Resilience Style Guide → раздел 8. Конфигурация.

Важно знать

  • Параметры CB/retry/bulkhead/timeout — через типизированный конфиг (class-validator или zod), не литералами в wrap(...).
  • Конфиг читается при старте (ConfigModule); невалидные значения роняют приложение до того, как оно примет трафик.
  • Дефолты — в базовом объекте, per-system override — отдельной секцией client.<system>.
  • Имя DI-токена и имя системы совпадают (SBER_POLICY, RECEIPT_POLICY; коротко: sber, receipt, не sber-payment-service-prod).
  • Policy-объект — singleton в DI; не пересоздаётся на каждый вызов — иначе CB теряет накопленную статистику.
  • Магические числа в wrap(retry(..., { maxAttempts: 3 }), circuitBreaker(..., { halfOpenAfter: 30_000 })) — скрытая конфигурация: нельзя поменять без redeploy.
  • class-validator с validateSync / plainToInstance или zod.parse — дают fail-fast при старте и тип в TypeScript одновременно.

Resilience-параметры — это операционные рычаги: их подкручивают по данным мониторинга (failure_rate CB, percentile latency). Если цифры вшиты в wrap(...), каждая правка — это PR, сборка, выкатка. Типизированный конфиг, который читается из переменных окружения через ConfigService, убирает этот барьер.

Типизированный конфиг per-system

R-RES-CFG-1/R-RES-CFG-2 — конфиг через ConfigModule; дефолты в одном месте, per-system override отдельной секцией.

// src/config/resilience.config.ts
import { IsInt, IsNumber, IsOptional, Min, Max, validateSync } from 'class-validator';
import { plainToInstance } from 'class-transformer';

export class CircuitBreakerConfig {
  @IsInt() @Min(10) @Max(200) slidingWindowSize: number = 50;
  @IsNumber() @Min(0.1) @Max(1.0) failureRateThreshold: number = 0.5;
  @IsInt() @Min(5_000) halfOpenAfterMs: number = 30_000;
  @IsInt() @Min(1) halfOpenAttempts: number = 3;
}

export class RetryConfig {
  @IsInt() @Min(1) @Max(5) maxAttempts: number = 3;
  @IsInt() @Min(100) initialDelayMs: number = 500;
}

export class BulkheadConfig {
  @IsInt() @Min(1) maxConcurrent: number = 10;
  @IsInt() @Min(0) queueLimit: number = 0;
}

export class TimeoutConfig {
  @IsInt() @Min(500) connectTimeoutMs: number = 1_000;
  @IsInt() @Min(500) readTimeoutMs: number = 5_000;
  @IsInt() @Min(500) totalTimeoutMs: number = 8_000;
}

export class SystemResilienceConfig {
  cb: CircuitBreakerConfig = new CircuitBreakerConfig();
  retry: RetryConfig = new RetryConfig();
  bulkhead: BulkheadConfig = new BulkheadConfig();
  timeout: TimeoutConfig = new TimeoutConfig();
}

export class ResilienceConfig {
  defaults: SystemResilienceConfig = new SystemResilienceConfig();
  sber: Partial<SystemResilienceConfig> = {};
  receipt: Partial<SystemResilienceConfig> = {};
  insurance: Partial<SystemResilienceConfig> = {};
  customer: Partial<SystemResilienceConfig> = {};
}

export function loadResilienceConfig(raw: Record<string, unknown>): ResilienceConfig {
  const cfg = plainToInstance(ResilienceConfig, raw['resilience'] ?? {});
  const errors = validateSync(cfg, { whitelist: true });
  if (errors.length) throw new Error(`Invalid resilience config:\n${errors}`);
  return cfg;
}

export function resolveSystem(cfg: ResilienceConfig, system: keyof Omit<ResilienceConfig, 'defaults'>): SystemResilienceConfig {
  return {
    cb: { ...cfg.defaults.cb, ...(cfg[system]?.cb ?? {}) },
    retry: { ...cfg.defaults.retry, ...(cfg[system]?.retry ?? {}) },
    bulkhead: { ...cfg.defaults.bulkhead, ...(cfg[system]?.bulkhead ?? {}) },
    timeout: { ...cfg.defaults.timeout, ...(cfg[system]?.timeout ?? {}) },
  };
}

.env / Kubernetes ConfigMap:

RESILIENCE__DEFAULTS__CB__SLIDING_WINDOW_SIZE=50
RESILIENCE__DEFAULTS__CB__FAILURE_RATE_THRESHOLD=0.5
RESILIENCE__DEFAULTS__TIMEOUT__TOTAL_TIMEOUT_MS=8000

# Sber — критичная система, CB открывается раньше
RESILIENCE__SBER__CB__FAILURE_RATE_THRESHOLD=0.3
RESILIENCE__SBER__CB__HALF_OPEN_AFTER_MS=30000
RESILIENCE__SBER__TIMEOUT__TOTAL_TIMEOUT_MS=6000

# Receipt — медленнее, таймаут выше
RESILIENCE__RECEIPT__TIMEOUT__TOTAL_TIMEOUT_MS=15000
RESILIENCE__RECEIPT__TIMEOUT__READ_TIMEOUT_MS=12000

Policy-фабрика — singleton в DI

R-RES-CFG-3 — имя DI-токена совпадает с именем системы. Policy собирается фабрикой один раз при старте модуля.

// src/resilience/resilience-policy.factory.ts
import {
  retry, circuitBreaker, bulkhead, timeout,
  ExponentialBackoff, CountBreaker, TimeoutStrategy,
  BrokenCircuitError, wrap,
} from 'cockatiel';
import { SystemResilienceConfig } from '../config/resilience.config';

export type ResiliencePolicy = ReturnType<typeof buildPolicy>;

export function buildPolicy(cfg: SystemResilienceConfig) {
  return wrap(
    retry(handleType(Error), {
      maxAttempts: cfg.retry.maxAttempts,
      backoff: new ExponentialBackoff({ initialDelay: cfg.retry.initialDelayMs }),
    }),
    circuitBreaker(handleAll, {
      halfOpenAfter: cfg.cb.halfOpenAfterMs,
      breaker: new CountBreaker({
        threshold: cfg.cb.failureRateThreshold,
        size: cfg.cb.slidingWindowSize,
      }),
    }),
    bulkhead(cfg.bulkhead.maxConcurrent, { queueLimit: cfg.bulkhead.queueLimit }),
    timeout(cfg.timeout.totalTimeoutMs, TimeoutStrategy.Aggressive),
  );
}
// src/resilience/resilience.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { loadResilienceConfig, resolveSystem } from '../config/resilience.config';
import { buildPolicy } from './resilience-policy.factory';

export const SBER_POLICY = Symbol('SBER_POLICY');
export const RECEIPT_POLICY = Symbol('RECEIPT_POLICY');
export const ORDER_SERVICE_POLICY = Symbol('ORDER_SERVICE_POLICY');

@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: SBER_POLICY,
      useFactory: (cs: ConfigService) =>
        buildPolicy(resolveSystem(loadResilienceConfig(cs.get('') ?? {}), 'sber')),
      inject: [ConfigService],
    },
    {
      provide: RECEIPT_POLICY,
      useFactory: (cs: ConfigService) =>
        buildPolicy(resolveSystem(loadResilienceConfig(cs.get('') ?? {}), 'receipt')),
      inject: [ConfigService],
    },
  ],
  exports: [SBER_POLICY, RECEIPT_POLICY],
})
export class ResilienceModule {}

Адаптер получает готовую policy через инжекцию — он не знает про числа:

// src/adapters/out/sber/sber.adapter.ts
@Injectable()
export class SberAdapter implements PaymentPort {
  constructor(
    @Inject(SBER_CLIENT) private readonly client: Agent,
    @Inject(SBER_POLICY) private readonly policy: ResiliencePolicy,
  ) {}

  async findPayment(ref: PaymentRef): Promise<Payment> {
    try {
      const resp = await this.policy.execute(() =>
        this.client.request({ path: `/payments/${ref.id}`, method: 'GET' }),
      );
      return toPaymentDomain(await resp.body.json());
    } catch (e) {
      if (e instanceof BrokenCircuitError) throw PaymentPortError.systemUnavailable('sber', e);
      throw PaymentPortError.from(e);
    }
  }
}

Дефолты и per-system override

R-RES-CFG-2 — только реальные отклонения выносятся в per-system секцию. Для нового сервиса без аномалий достаточно:

# Новая система Customer — наследует все дефолты
# Ничего добавлять не нужно, resolveSystem(cfg, 'customer') вернёт defaults

Три случая, когда per-system override обоснован:

СитуацияЧто меняемПример
Критичная денежная операцияcb.failureRateThreshold внизSber: 0.50.3
Медленный внешний сервисtimeout.readTimeoutMs, timeout.totalTimeoutMsReceipt: 500012000
Высоконагруженный сервисbulkhead.maxConcurrent, cb.slidingWindowSizeInsurance: 1020

Любое отклонение от дефолта — сигнал: нужно обоснование в комментарии к переменной окружения или в документации системы.

Имена DI-токенов — same as system

R-RES-CFG-3 + R-RES-ISO-3: SBER_POLICYSBER_CLIENTSberHealthIndicator ↔ имя в метриках circuit_breaker{system="sber"}.

// PREFER — короткое, согласованное имя
export const SBER_POLICY = Symbol('SBER_POLICY');
export const SBER_CLIENT = Symbol('SBER_CLIENT');

// AVOID — длинные суффиксы ломают согласованность
export const SBER_PAYMENT_SERVICE_RESILIENCE_POLICY = Symbol('...');

Сокращение важно не ради стиля — согласованное имя позволяет SRE по метрике sber найти соответствующую policy, клиент и health-индикатор без поиска по коду.

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

АнтипаттернПравилоЧто взамен
Магические числа в wrap(retry(..., { maxAttempts: 3 })) без конфигаR-RES-CFG-X1buildPolicy(resolveSystem(cfg, 'sber'))
Policy пересоздаётся на каждый вызов (new CountBreaker(...) в execute)R-RES-CFG-X1Singleton-провайдер в ResilienceModule
Разные имена для CB / bulkhead / retry одной системы (sberCb, payment-bulkhead)R-RES-CFG-3Единый символ SBER_POLICY
Per-system override без обоснованияR-RES-CFG-2Комментарий или ссылка на инцидент
Конфиг без валидации (class-validator / zod)R-RES-CFG-1validateSync + plainToInstance при старте

Куда дальше

  • Per-system isolation — SBER_CLIENT отдельно от RECEIPT_CLIENT, sizing пула.
  • Circuit Breaker — CountBreaker, пороги, BrokenCircuitError → port-исключение.
  • Bulkhead — bulkhead(maxConcurrent, queueLimit), отличие от connection pool.
  • Retry — ExponentialBackoff, handleType, ограничение идемпотентными методами.
  • Timeouts — иерархия connect < read < total, AbortSignal.timeout.
  • Observability — prom-client gauges/counters для cockatiel-событий, WARN на CB-переходы.
  • Health checks — @nestjs/terminus с TTL-кешем per-system.
  • Fallback — BrokenCircuitError → деградация, когда fallback недопустим.
  • Async и polling — task-queue вместо setTimeout-цикла.
  • Where protection goes — outbound vs internal vs inbound.
  • OpenAPI generator binding — policy на адаптере, не на сгенерированном клиенте.