Опирается на правила:
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.5 → 0.3 |
| Медленный внешний сервис | timeout.readTimeoutMs, timeout.totalTimeoutMs | Receipt: 5000 → 12000 |
| Высоконагруженный сервис | bulkhead.maxConcurrent, cb.slidingWindowSize | Insurance: 10 → 20 |
Любое отклонение от дефолта — сигнал: нужно обоснование в комментарии к переменной окружения или в документации системы.
Имена DI-токенов — same as system
R-RES-CFG-3 + R-RES-ISO-3: SBER_POLICY ↔ SBER_CLIENT ↔ SberHealthIndicator ↔ имя в метриках 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-X1 | buildPolicy(resolveSystem(cfg, 'sber')) |
Policy пересоздаётся на каждый вызов (new CountBreaker(...) в execute) | R-RES-CFG-X1 | Singleton-провайдер в 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-1 | validateSync + 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-clientgauges/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 на адаптере, не на сгенерированном клиенте.