Опирается на правила:
R-RES-TO-1…R-RES-TO-3иR-RES-TO-X1…R-RES-TO-X3из Resilience Style Guide → раздел 3. Timeouts.
Важно знать
- Иерархия:
connectTimeout < headersTimeout ≤ bodyTimeout, плюс cockatieltimeout()как общий cap на вызов.connectTimeout— TCP/TLS handshake. Локальная сеть: 1s. Внешний интернет: 3–5s.headersTimeout— время до получения HTTP-статуса и заголовков; у axios этоtimeout(он же read-level). Быстрые API: 5–10s, тяжёлые: 20–30s.bodyTimeout— пауза между чанками тела ответа. Актуален для потоковых ответов; для JSON-API обычно равенheadersTimeout.- cockatiel
timeout(total)— общий cap: должен быть> connectTimeout + headersTimeout + buffer.- Все параметры — per-system через типизированный конфиг (
SberClientConfig, zod/class-validator), не magic-numbers вnew Agent(...).- При
traceparentс TimeBudget — NestJS-интерцептор ставитAbortSignal.timeout(min(total, remainingBudget - 100ms)).axiosпо умолчаниюtimeout: 0(∞).fetchбезAbortSignal— тоже ∞. Явные значения обязательны.
Без явных timeouts outbound HTTP способен зависнуть навечно: TCP-соединение установилось, но байты тела не приходят. При параллельных запросах это быстро накапливает pending-промисы в event loop. Решение — три уровня ограничений с понятной иерархией плюс policy-обёртка поверх. Статья раскрывает раздел 3 гайда в идиомах Node/NestJS.
Иерархия трёх уровней
R-RES-TO-1: undici разделяет три отдельных timeout, каждый ловит разный сбой.
| Уровень | undici-параметр | axios-эквивалент | Что ловит | Типовое значение |
|---|---|---|---|---|
| TCP/TLS handshake | connectTimeout | httpsAgent connectTimeout | host down, network partition | 1s локально, 3–5s через интернет |
| Первый байт / заголовки | headersTimeout | timeout | медленный backend, очередь обработки | 5–10s быстрые, 20–30s тяжёлые |
| Чанк тела | bodyTimeout | — | зависший поток ответа | = headersTimeout для JSON |
| Весь вызов (cap) | — (cockatiel) | — (cockatiel) | медленный сервер шлёт данные мелкими кусками | > connect + headers + 1s |
Почему все уровни нужны: headersTimeout не поможет, если сервер присылает по байту каждые 25 секунд (каждый чанк в норме, но вызов бесконечен). bodyTimeout отрежет паузы между чанками. Cockatiel timeout() — последняя страховка по полному времени.
Per-system конфиг через class-validator
R-RES-TO-2: таймауты — не литералы в new Agent(...), а типизированный конфиг, который падает на старте при ошибке.
// src/adapters/out/sber/sber-client.config.ts
import { IsUrl, IsInt, Min, Max } from 'class-validator';
export class SberClientConfig {
@IsUrl() baseUrl!: string;
@IsInt() @Min(500) @Max(5_000) connectTimeout!: number; // ms
@IsInt() @Min(1_000) @Max(30_000) headersTimeout!: number;
@IsInt() @Min(1_000) @Max(30_000) bodyTimeout!: number;
@IsInt() @Min(2_000) @Max(60_000) callTimeout!: number; // cockatiel cap
@IsInt() @Min(1) @Max(100) connections!: number;
}
# application.yml
client:
sber:
base-url: https://api.sber.example.com
connect-timeout: 3000
headers-timeout: 15000
body-timeout: 15000
call-timeout: 19000 # > connect + headers + 1s buffer
connections: 10
customer:
base-url: https://api.customers.internal
connect-timeout: 1000
headers-timeout: 5000
body-timeout: 5000
call-timeout: 7000
connections: 20
product:
base-url: https://catalog.internal
connect-timeout: 1000
headers-timeout: 10000
body-timeout: 10000
# Каталог с большой выдачей: body-timeout увеличен, поэтому callTimeout тоже ↑
call-timeout: 12000
connections: 15
Провайдер undici-клиента в DI:
// src/adapters/out/sber/sber.providers.ts
import { Agent } from 'undici';
import { SberClientConfig } from './sber-client.config';
export const SBER_CLIENT = Symbol('SBER_CLIENT');
export const sberClientProvider = {
provide: SBER_CLIENT,
inject: [SberClientConfig],
useFactory: (cfg: SberClientConfig) =>
new Agent({
connections: cfg.connections,
connect: { timeout: cfg.connectTimeout },
headersTimeout: cfg.headersTimeout,
bodyTimeout: cfg.bodyTimeout,
}),
};
Поверх — cockatiel timeout() как общий cap:
// src/adapters/out/sber/sber.adapter.ts
import { Injectable, Inject } from '@nestjs/common';
import { wrap, timeout, TimeoutStrategy, circuitBreaker, bulkhead,
CountBreaker, BrokenCircuitError, TaskCancelledError } from 'cockatiel';
import { Agent, request as undiciRequest } from 'undici';
import { PaymentPort, PaymentPortError } from '../../../core/port/payment.port';
import { SberClientConfig } from './sber-client.config';
import { SBER_CLIENT } from './sber.providers';
import { toPayment } from './sber.mapper';
@Injectable()
export class SberAdapter implements PaymentPort {
private readonly policy = wrap(
circuitBreaker(handleAll, {
halfOpenAfter: 30_000,
breaker: new CountBreaker({ threshold: 0.5, size: 50 }),
}),
bulkhead(8),
timeout(this.cfg.callTimeout, TimeoutStrategy.Aggressive),
);
constructor(
private readonly cfg: SberClientConfig,
@Inject(SBER_CLIENT) private readonly agent: Agent,
) {}
async findPaymentByOrderId(orderId: string): Promise<Payment | null> {
try {
const resp = await this.policy.execute(({ signal }) =>
undiciRequest(`${this.cfg.baseUrl}/payments?orderId=${orderId}`, {
method: 'GET',
dispatcher: this.agent,
signal,
}),
);
if (resp.statusCode === 404) return null;
return toPayment(await resp.body.json());
} catch (e) {
if (e instanceof BrokenCircuitError)
throw PaymentPortError.systemUnavailable('sber', e);
if (e instanceof TaskCancelledError)
throw PaymentPortError.timeout('sber', e);
throw PaymentPortError.from(e);
}
}
}
TimeoutStrategy.Aggressive прерывает через AbortSignal: когда callTimeout истёк, выполнение не ждёт завершения промиса.
Уважение TimeBudget при traceparent
R-RES-TO-3: если входящий запрос содержит traceparent + X-Time-Budget, client-side timeout сжимается до оставшегося бюджета.
// src/common/interceptors/time-budget.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { TimeBudgetContext } from '../time-budget.context';
@Injectable()
export class TimeBudgetInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = ctx.switchToHttp().getRequest();
const budget = Number(req.headers['x-time-budget']);
if (budget > 0) TimeBudgetContext.set(budget);
return next.handle();
}
}
// В адаптере — утилита для вычисления эффективного timeout:
function effectiveTimeout(callTimeout: number): number {
const budget = TimeBudgetContext.get();
if (budget == null) return callTimeout;
const remaining = budget - Date.now();
if (remaining < 100) throw PaymentPortError.budgetExhausted();
return Math.min(callTimeout, remaining - 100);
}
// Использование в policy.execute:
const resp = await this.policy.execute(({ signal: _s }) => {
const ms = effectiveTimeout(this.cfg.callTimeout);
return undiciRequest(`${this.cfg.baseUrl}/payments?orderId=${orderId}`, {
method: 'GET',
dispatcher: this.agent,
signal: AbortSignal.timeout(ms),
});
});
Зачем: если upstream-вызов сказал «у тебя 3 секунды», а наш outbound рассчитан на 15 секунд — upstream уже закрыл соединение и ответ уйдёт в никуда. Fail-fast раньше = меньше ресурсов расходуется впустую.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
axios.create({}) без timeout или timeout: 0 | R-RES-TO-X1 | Явные headersTimeout + cockatiel timeout(callTimeout) |
fetch(url) без AbortSignal | R-RES-TO-X1 | AbortSignal.timeout(ms) или cockatiel timeout() |
callTimeout < headersTimeout или callTimeout < connectTimeout | R-RES-TO-X2 | callTimeout ≥ connectTimeout + headersTimeout + buffer |
headersTimeout > 60_000 для sync HTTP-handler'а | R-RES-TO-X3 | Task-queue с polling через @Interval |
Magic-numbers прямо в new Agent(...) | R-RES-TO-2 | Типизированный конфиг SberClientConfig с class-validator |
Игнорирование TimeBudget при наличии traceparent | R-RES-TO-3 | Interceptor + effectiveTimeout() |
Один глобальный axios-дефолт / fetch без агента для всех систем | R-RES-ISO-X1 | Per-system Agent / axios.create() провайдер |
Куда дальше
- Per-system isolation — отдельный undici
Agentна каждую внешнюю систему. - Circuit Breaker —
CountBreakerсрабатывает раньше timeout при медленных вызовах. - Bulkhead — ограничение числа параллельных вызовов к одной системе.
- Retry —
ExponentialBackoffтолько при идемпотентности; без cockatiel-retry нет согласования с CB. - Async и polling — если операция требует более 60 секунд.
- Конфигурация — декларативная сборка policy-набора из конфига.
- Observability —
prom-clientметрики таймаутов и CB-переходов. - Health checks —
@nestjs/terminusи TTL-кеш probe. - Fallback — обработка
TaskCancelledErrorпри срабатывании timeout. - Where protection goes — на каком слое ставить timeout, CB, bulkhead.
- OpenAPI generator binding — policy на public-методе адаптера, не на сгенерированном клиенте.