Опирается на правила: R-RES-TO-1R-RES-TO-3 и R-RES-TO-X1R-RES-TO-X3 из Resilience Style Guide → раздел 3. Timeouts.

Важно знать

  • Иерархия: connectTimeout < headersTimeout ≤ bodyTimeout, плюс cockatiel timeout() как общий 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 handshakeconnectTimeouthttpsAgent connectTimeouthost down, network partition1s локально, 3–5s через интернет
Первый байт / заголовкиheadersTimeouttimeoutмедленный 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: 0R-RES-TO-X1Явные headersTimeout + cockatiel timeout(callTimeout)
fetch(url) без AbortSignalR-RES-TO-X1AbortSignal.timeout(ms) или cockatiel timeout()
callTimeout < headersTimeout или callTimeout < connectTimeoutR-RES-TO-X2callTimeout ≥ connectTimeout + headersTimeout + buffer
headersTimeout > 60_000 для sync HTTP-handler'аR-RES-TO-X3Task-queue с polling через @Interval
Magic-numbers прямо в new Agent(...)R-RES-TO-2Типизированный конфиг SberClientConfig с class-validator
Игнорирование TimeBudget при наличии traceparentR-RES-TO-3Interceptor + effectiveTimeout()
Один глобальный axios-дефолт / fetch без агента для всех системR-RES-ISO-X1Per-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-методе адаптера, не на сгенерированном клиенте.