Как делать11 октября 2026 г.10 мин чтения
API и интеграцииШаг 5 из 8

Внешние API: таймауты, повторы, лимиты и деградация

Коротко

Каждый вызов чужого API нужно ограничить таймаутом на попытку и на всю операцию, повторять только временные сбои (сеть, таймаут, 408, 429, 5xx) и только для идемпотентных запросов, а паузы между попытками делать экспоненциальными со случайным разбросом (jitter) и с учётом заголовка Retry-After. Если сервис лежит долго, circuit breaker перестаёт его вызывать, а приложение отвечает из кэша или честной заглушкой, не роняя свои страницы.

РуководствоУровень: СреднийВнешние API и обработка ошибокПрактика: ~1–2 ч

После материала вы сможете

  • задать таймаут на попытку и общий срок через AbortSignal.timeout и AbortSignal.any и отличить таймаут от отмены
  • классифицировать ошибки внешнего API и повторять только временные сбои идемпотентных запросов, а POST — только с Idempotency-Key
  • реализовать экспоненциальную задержку с Full Jitter, учесть 429 и Retry-After и ограничить свой темп маркерной корзиной
  • включить circuit breaker и ответ из кэша при отказе и проверить всё тестами против локального капризного сервера

Проверено на

Node.js 24 LTS (24.18)TypeScript 7.0 (tsc 7.0.2)@types/node 24Fetch API, AbortSignal.timeout / AbortSignal.anynode:test

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Чужой API всегда может упасть
  2. 02Таймауты: на попытку и на всю операцию
  3. 03Какие ошибки повторять, а какие нет
  4. 04Экспоненциальная задержка и jitter
  5. 05Лимиты: 429, Retry-After и собственный rate limit
  6. 06Circuit breaker и деградация
  7. 07Проверка: капризный сервер, тесты и журнал вызовов

Чужой API всегда может упасть

Платёжный шлюз, курсы валют, CRM, служба доставки — всё это чужие серверы со своими сбоями, обновлениями и лимитами. Если код просто вызывает await fetch(url), то зависший сервис держит ваш запрос и соединение, всплеск 503 превращается в ошибки на ваших страницах, а наивные мгновенные повторы добивают сервис, который пытается подняться. Задача — заранее решить для каждого вызова, сколько ждать, что повторять и что показать, если ответа нет.

Нет соединения, обрыв

Как выглядит в Node.js
fetch отклоняется с TypeError
Что делаем
Повтор с паузой, если запрос идемпотентный

Сервис завис

Как выглядит в Node.js
Ответа нет; с таймаутом — TimeoutError
Что делаем
Таймаут на попытку, затем повтор

Временная ошибка сервера

Как выглядит в Node.js
500, 502, 503, 504, 408
Что делаем
Повтор с экспоненциальной паузой

Превышен лимит

Как выглядит в Node.js
429, часто с Retry-After
Что делаем
Ждать столько, сколько просит сервер, или отдать задачу в фон

Ошибка в нашем запросе

Как выглядит в Node.js
400, 401, 403, 404, 422
Что делаем
Не повторять: ответ не изменится

Сервис лежит долго

Как выглядит в Node.js
Подряд одни отказы
Что делаем
Circuit breaker и деградация: кэш или заглушка
  1. 01Сервис приложенияКэш и деградация: что показать, если ответа нет
  2. 02Circuit breakerНе вызывать сервис, который лежит
  3. 03Rate limiterНе превышать лимит провайдера
  4. 04fetchWithRetryТаймауты, классификация ошибок, backoff, Retry-After
  5. 05Внешний APIЧужой сервер со своими сбоями и лимитами
Слои защиты из этой статьи — сверху вниз по пути запроса.

Материал для backend-разработчиков, которые уже уверенно пишут async/await и обрабатывают ошибки. Код проверен на Node.js 24.18 и TypeScript 7.0; внешних зависимостей нет, только встроенные fetch, AbortSignal и node:test. Node.js 24 запускает .ts напрямую: удаление типов (type stripping) стабильно с версии 24.12, а tsc проверяет типы. Здесь — механика надёжного вызова; типизированный клиент конкретного API со схемами ответов разобран в отдельном рецепте.

bash
mkdir resilient-fetch
cd resilient-fetch
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.test="node --test" scripts.typecheck="tsc"
npm install -D typescript @types/node@24
mkdir src test
Проект: ES-модули, TypeScript только для проверки типов
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Настройки tsc: только стираемый синтаксис, импорт с расширением .ts

Таймауты: на попытку и на всю операцию

Таймаутов нужно два. Таймаут попытки обрывает один зависший запрос и даёт шанс повтору. Общий срок (deadline) ограничивает всю операцию вместе с повторами и паузами: пользователь или вызывающий сервис не будут ждать дольше заданного, сколько бы попыток ни осталось. Оба делаются стандартными сигналами: AbortSignal.timeout(ms) срабатывает сам через заданное время, а AbortSignal.any([...]) объединяет его с сигналом отмены от вызывающего кода. По MDN, по таймауту сигнал отменяет операцию с ошибкой TimeoutError, а ручная отмена даёт AbortError — так обёртка отличает «сервер не успел» от «нас отменили».

Как выбрать число. В материале AWS Builders Library о таймаутах советуют начинать с метрик задержки вызываемого сервиса: выбрать допустимую долю ложных таймаутов, например 0,1 %, и взять соответствующий перцентиль — p99.9. Если метрик ещё нет, по нашему опыту разумно начать с 2–5 секунд на попытку для обычного JSON-API и сразу записывать длительность каждого вызова, чтобы потом подстроить значение по фактам.

В обёртке ниже каждая попытка получает сигнал AbortSignal.any([overall, AbortSignal.timeout(attemptTimeoutMs)]), где overall — общий срок плюс отмена от вызывающего. Пауза между попытками тоже слушает overall, поэтому общий срок не «проспит» долгую паузу.

Какие ошибки повторять, а какие нет

Повтор помогает только от временных сбоев. Ответ 400 или 401 на тот же запрос не изменится, сколько ни повторяй, — это ошибка в данных или ключе, её нужно показать и исправить. Второй вопрос — безопасно ли повторять вообще. RFC 9110 определяет идемпотентные методы — PUT, DELETE и безопасные GET, HEAD, OPTIONS: несколько одинаковых запросов дают тот же эффект, что и один, и именно поэтому их можно повторить при сбое связи. Для неидемпотентных методов стандарт говорит: клиенту не следует (SHOULD NOT) повторять их автоматически, если он не знает, что запрос на деле идемпотентен. AWS формулирует то же короче: вызовы с побочными эффектами небезопасно повторять, если API не поддерживает идемпотентность.

Сетевая ошибка, обрыв соединения

Повторять?
Да, если метод идемпотентный или есть ключ
Комментарий
Запрос мог дойти до сервера до обрыва

Таймаут попытки

Повторять?
Да, с тем же условием
Комментарий
Сервер мог выполнить действие

408 Request Timeout

Повторять?
Да
Комментарий
RFC 9110 разрешает клиенту повторить запрос

429 Too Many Requests

Повторять?
Да, после Retry-After
Комментарий
Если ждать дольше допустимого — отдать в фон

500, 502, 503, 504

Повторять?
Да, с экспоненциальной паузой
Комментарий
503 тоже может прийти с Retry-After

Прочие 4xx

Повторять?
Нет
Комментарий
Ответ возвращается вызывающему как есть

Отмена вызывающим кодом

Повторять?
Никогда
Комментарий
Пользователь ушёл — ответ уже никому не нужен

Для POST обёртка повторяет запрос, только если в заголовках есть Idempotency-Key: сервер, который поддерживает такой ключ, узнает повтор и вернёт прежний результат вместо второй записи. Как это устроено на стороне сервера, разобрано в материале о проектировании API; ключ генерируется один раз на операцию и одинаков во всех попытках.

Отказ внешнего сервиса после всех попыток обёртка выражает отдельным классом ошибки. Это важно для следующих слоёв: circuit breaker и кэш реагируют только на UpstreamError, а 4xx о нашем собственном запросе не должен ни открывать предохранитель, ни прятаться за старым кэшем.

src/errors.ts
export type FailureKind = "network" | "timeout" | "deadline" | "http";

// The external service failed: network, timeout or 408/429/5xx after all attempts.
// A 4xx about our own request is not an UpstreamError: the response is returned as is.
export class UpstreamError extends Error {
  readonly kind: FailureKind;
  readonly status: number | undefined;
  readonly attempts: number;
  readonly retryAfterMs: number | undefined;

  constructor(
    message: string,
    details: { kind: FailureKind; attempts: number; status?: number; retryAfterMs?: number; cause?: unknown },
  ) {
    super(message, { cause: details.cause });
    this.name = "UpstreamError";
    this.kind = details.kind;
    this.status = details.status;
    this.attempts = details.attempts;
    this.retryAfterMs = details.retryAfterMs;
  }
}

export class CircuitOpenError extends Error {
  constructor(name: string) {
    super(`Circuit "${name}" is open: the call was not made`);
    this.name = "CircuitOpenError";
  }
}
Два класса ошибок: отказ внешнего сервиса и открытый предохранитель

Экспоненциальная задержка и jitter

Повторять сразу бессмысленно: сервис, который только что ответил 503, через миллисекунду скорее всего ответит так же. Экспоненциальная задержка удваивает паузу после каждой неудачи, а верхняя граница (cap) не даёт ей расти бесконечно. Но если сбой задел тысячи клиентов одновременно, их повторы снова придут синхронными волнами. В статье AWS Architecture Blog «Exponential Backoff And Jitter» (Marc Brooker) это показано моделированием и сравнены варианты случайного разброса — jitter:

Экспонента с границей

Формула из статьи AWS
sleep = min(cap, base * 2 ** attempt)
Что говорит статья
Клиенты продолжают конкурировать волнами

Full Jitter

Формула из статьи AWS
sleep = random_between(0, min(cap, base * 2 ** attempt))
Что говорит статья
Меньше всего работы для сервера, чуть дольше по времени

Equal Jitter

Формула из статьи AWS
temp = min(cap, base * 2 ** attempt); sleep = temp / 2 + random_between(0, temp / 2)
Что говорит статья
Худший из вариантов с разбросом

Decorrelated Jitter

Формула из статьи AWS
sleep = min(cap, random_between(base, sleep * 3))
Что говорит статья
Выбор между ним и Full Jitter, по словам автора, «менее очевиден»

Вывод статьи: jitter следует считать стандартным подходом для удалённых клиентов. Мы берём Full Jitter — он проще всего и даёт меньше всего лишних вызовов. С base = 200 мс и cap = 5000 мс верхняя граница паузы растёт так: 200, 400, 800, 1600, 3200, 5000 мс, а фактическая пауза — случайное число от нуля до этой границы.

src/fetch-with-retry.ts
import { setTimeout as delay } from "node:timers/promises";
import { UpstreamError } from "./errors.ts";

export type AttemptLog = {
  method: string;
  url: string;
  attempt: number;
  outcome: number | "timeout" | "network";
  ms: number;
  next: "done" | "retry" | "give-up";
  delayMs?: number;
};

export type RetryOptions = {
  retries?: number; // extra attempts after the first one
  baseDelayMs?: number;
  maxDelayMs?: number; // cap for the exponential delay
  attemptTimeoutMs?: number; // one attempt
  totalTimeoutMs?: number; // all attempts and pauses together
  maxRetryAfterMs?: number; // a longer Retry-After means "give up now"
  signal?: AbortSignal; // cancellation by the caller
  log?: (entry: AttemptLog) => void;
  sleep?: (ms: number, signal: AbortSignal) => Promise<unknown>;
  random?: () => number;
};

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);

// RFC 9110: do not repeat a non-idempotent request automatically,
// unless the API accepts an idempotency key for it
export function canRetry(method: string, headers: Headers): boolean {
  return IDEMPOTENT_METHODS.has(method) || headers.has("idempotency-key");
}

// Retry-After is either delay-seconds or an HTTP-date (RFC 9110, section 10.2.3)
export function parseRetryAfter(value: string | null, now = Date.now()): number | undefined {
  if (value === null) return undefined;
  const trimmed = value.trim();
  if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000;
  const date = Date.parse(trimmed);
  return Number.isNaN(date) ? undefined : Math.max(0, date - now);
}

// "Full Jitter": random_between(0, min(cap, base * 2 ** attempt))
export function backoffDelay(attempt: number, baseMs: number, capMs: number, random = Math.random): number {
  return Math.round(random() * Math.min(capMs, baseMs * 2 ** attempt));
}

export async function fetchWithRetry(
  url: string | URL,
  init: RequestInit = {},
  options: RetryOptions = {},
): Promise<Response> {
  const {
    retries = 3,
    baseDelayMs = 200,
    maxDelayMs = 5_000,
    attemptTimeoutMs = 2_000,
    totalTimeoutMs = 10_000,
    maxRetryAfterMs = 30_000,
    signal: callerSignal,
    log = () => {},
    sleep = (ms, signal) => delay(ms, undefined, { signal }),
    random = Math.random,
  } = options;

  const method = (init.method ?? "GET").toUpperCase();
  const maxAttempts = canRetry(method, new Headers(init.headers)) ? retries + 1 : 1;
  const deadline = AbortSignal.timeout(totalTimeoutMs);
  const deadlineAt = Date.now() + totalTimeoutMs;
  const overall = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
  const target = String(url);

  for (let attempt = 1; ; attempt++) {
    const started = performance.now();
    const entry = (outcome: AttemptLog["outcome"]) => ({
      method, url: target, attempt, outcome, ms: Math.round(performance.now() - started),
    });
    let failure: UpstreamError;
    let outcome: AttemptLog["outcome"];

    try {
      const res = await fetch(url, {
        ...init,
        signal: AbortSignal.any([overall, AbortSignal.timeout(attemptTimeoutMs)]),
      });
      outcome = res.status;
      if (!RETRYABLE_STATUS.has(res.status)) {
        log({ ...entry(outcome), next: "done" });
        return res; // 2xx, 3xx and our own 4xx: the caller decides
      }
      await res.body?.cancel(); // release the connection
      failure = new UpstreamError(`${method} ${target} answered ${res.status}`, {
        kind: "http",
        status: res.status,
        attempts: attempt,
        retryAfterMs: parseRetryAfter(res.headers.get("retry-after")),
      });
    } catch (error) {
      if (callerSignal?.aborted) throw error; // cancelled by the caller: no retries
      const timedOut = error instanceof DOMException && error.name === "TimeoutError";
      outcome = timedOut ? "timeout" : "network";
      if (deadline.aborted) {
        log({ ...entry(outcome), next: "give-up" });
        throw new UpstreamError(`${method} ${target}: total timeout ${totalTimeoutMs} ms`, {
          kind: "deadline", attempts: attempt, cause: error,
        });
      }
      failure = new UpstreamError(`${method} ${target}: ${timedOut ? "attempt timed out" : "network error"}`, {
        kind: timedOut ? "timeout" : "network", attempts: attempt, cause: error,
      });
    }

    const wait = failure.retryAfterMs ?? backoffDelay(attempt - 1, baseDelayMs, maxDelayMs, random);
    const giveUp =
      attempt >= maxAttempts ||
      (failure.retryAfterMs ?? 0) > maxRetryAfterMs ||
      Date.now() + wait >= deadlineAt;
    if (giveUp) {
      log({ ...entry(outcome), next: "give-up" });
      throw failure;
    }
    log({ ...entry(outcome), next: "retry", delayMs: wait });
    try {
      await sleep(wait, overall);
    } catch (error) {
      if (callerSignal?.aborted) throw error;
      throw failure; // the deadline came during the pause
    }
  }
}
Обёртка над fetch: таймауты, классификация, Full Jitter, Retry-After, общий срок и журнал попыток
  • retries = 3 — всего до четырёх попыток. Число повторов всегда ограничено; бесконечный цикл повторов — это нагрузочный тест чужого сервиса за ваш счёт.
  • res.body?.cancel() освобождает соединение у ответа, который мы не будем читать.
  • Перед паузой обёртка проверяет, успеет ли следующая попытка до общего срока; если нет — сдаётся сразу, а не спит впустую.
  • sleep и random передаются параметрами: в тестах паузы записываются, а не ожидаются, и проверка остаётся быстрой и детерминированной.

Лимиты: 429, Retry-After и собственный rate limit

Код 429 Too Many Requests из RFC 6585 означает, что клиент отправил слишком много запросов за отрезок времени. Сервер может (MAY) приложить Retry-After — сколько ждать перед новым запросом. Как сервер считает запросы и узнаёт клиента, стандарт не определяет: лимиты конкретного API всегда смотрите в его документации. По RFC 9110 значение Retry-After — либо число секунд (Retry-After: 120), либо дата в формате HTTP (Retry-After: Fri, 31 Dec 1999 23:59:59 GMT); вместе с 503 оно сообщает, сколько ещё сервис будет недоступен.

  1. Есть Retry-After

    Ждём ровно столько, сколько просит сервер, вместо своей экспоненты. parseRetryAfter понимает оба формата.

  2. Retry-After слишком большой

    Если ожидание длиннее maxRetryAfterMs (по умолчанию 30 секунд) или не укладывается в общий срок — сдаёмся сразу и отдаём значение в UpstreamError.retryAfterMs. Фоновая задача может перепланировать себя на это время.

  3. Не доводить до 429

    Ограничьте свой темп заранее: если провайдер разрешает 5 запросов в секунду, пусть клиент сам не отправляет больше.

Для собственного ограничения подходит маркерная корзина (token bucket): в корзине до capacity маркеров, они пополняются со скоростью ratePerSecond, каждый запрос забирает один. Короткий всплеск проходит сразу, длинный поток выравнивается до заданного темпа. AWS предлагает тот же механизм и для бюджета повторов: локально ограничивать, сколько повторов клиент вообще может себе позволить.

src/rate-limiter.ts
import { setTimeout as delay } from "node:timers/promises";

// Token bucket: a burst of up to `capacity` requests, then `ratePerSecond` on average.
// Keeps us under the provider's limit instead of collecting 429 responses.
export class RateLimiter {
  #capacity: number;
  #ratePerSecond: number;
  #tokens: number;
  #updatedAt = performance.now();

  constructor({ capacity, ratePerSecond }: { capacity: number; ratePerSecond: number }) {
    this.#capacity = capacity;
    this.#ratePerSecond = ratePerSecond;
    this.#tokens = capacity;
  }

  async acquire(signal?: AbortSignal): Promise<void> {
    for (;;) {
      const now = performance.now();
      const refilled = ((now - this.#updatedAt) / 1000) * this.#ratePerSecond;
      this.#tokens = Math.min(this.#capacity, this.#tokens + refilled);
      this.#updatedAt = now;
      if (this.#tokens >= 1) {
        this.#tokens -= 1;
        return;
      }
      const waitMs = Math.ceil(((1 - this.#tokens) / this.#ratePerSecond) * 1000);
      await delay(waitMs, undefined, { signal });
    }
  }
}
Маркерная корзина: всплеск до capacity, дальше — ratePerSecond в среднем

Circuit breaker и деградация

Повторы рассчитаны на короткие сбои. Если сервис лежит полчаса, каждый запрос всё равно отработает все попытки и таймауты, держа соединения и время пользователей. Паттерн circuit breaker (предохранитель) из Azure Architecture Center решает это так: после серии отказов предохранитель «размыкается» и сразу отвечает ошибкой, не вызывая сервис. Документация подчёркивает разницу: Retry рассчитывает, что операция в итоге пройдёт, а Circuit Breaker не даёт выполнять операцию, которая скорее всего упадёт. Сочетают их так: повторы идут через предохранитель.

Closed (замкнут)

Что происходит
Вызовы идут в сервис, отказы считаются
Переход
Порог отказов достигнут → Open

Open (разомкнут)

Что происходит
Вызов сразу завершается CircuitOpenError, сервис не трогаем
Переход
Прошло openMs → Half-Open

Half-Open (полуоткрыт)

Что происходит
Пропускается пробный вызов
Переход
Успех → Closed; отказ → снова Open
src/circuit-breaker.ts
import { CircuitOpenError, UpstreamError } from "./errors.ts";

export type CircuitState = "closed" | "open" | "half-open";

export type BreakerOptions = {
  name: string;
  failureThreshold?: number; // consecutive outages before opening
  openMs?: number; // how long to stay open before a trial call
  now?: () => number;
  onStateChange?: (from: CircuitState, to: CircuitState, name: string) => void;
};

export class CircuitBreaker {
  readonly name: string;
  #state: CircuitState = "closed";
  #failures = 0;
  #openedAt = 0;
  #trialInFlight = false;
  #threshold: number;
  #openMs: number;
  #now: () => number;
  #onStateChange: NonNullable<BreakerOptions["onStateChange"]>;

  constructor({ name, failureThreshold = 5, openMs = 30_000, now = Date.now, onStateChange = () => {} }: BreakerOptions) {
    this.name = name;
    this.#threshold = failureThreshold;
    this.#openMs = openMs;
    this.#now = now;
    this.#onStateChange = onStateChange;
  }

  get state(): CircuitState {
    return this.#state;
  }

  async run<T>(operation: () => Promise<T>): Promise<T> {
    if (this.#state === "open") {
      if (this.#now() - this.#openedAt < this.#openMs) throw new CircuitOpenError(this.name);
      this.#move("half-open");
    }
    if (this.#state === "half-open") {
      if (this.#trialInFlight) throw new CircuitOpenError(this.name); // one trial call at a time
      this.#trialInFlight = true;
    }
    try {
      const result = await operation();
      this.#failures = 0;
      if (this.#state === "half-open") this.#move("closed");
      return result;
    } catch (error) {
      // Only an outage of the external service counts; our own bugs and 4xx do not
      if (error instanceof UpstreamError) this.#recordFailure();
      throw error;
    } finally {
      this.#trialInFlight = false;
    }
  }

  #recordFailure() {
    this.#failures++;
    if (this.#state === "half-open" || this.#failures >= this.#threshold) {
      this.#failures = 0;
      this.#openedAt = this.#now();
      this.#move("open");
    }
  }

  #move(to: CircuitState) {
    const from = this.#state;
    this.#state = to;
    this.#onStateChange(from, to, this.name);
  }
}
Предохранитель: три состояния, один пробный вызов, учёт только отказов внешнего сервиса

Упрощения относительно описания Microsoft: у нас счётчик последовательных отказов, а в документации счётчик привязан ко времени и периодически сбрасывается, чтобы редкие отказы не размыкали цепь; в полуоткрытом состоянии мы пропускаем один пробный вызов, документация допускает «ограниченное число». Для учебного кода это честный компромисс; в продакшене часто берут готовую библиотеку и настраивают пороги по метрикам.

Деградация — что отдать пользователю, когда сервис недоступен. Документация Microsoft приводит пример: при размыкании приложение возвращает значения по умолчанию или ответы из кэша и сообщает пользователям о временной недоступности части данных. Сервис курсов валют ниже отвечает последним удачным результатом, если тот не старше maxStaleMs, и помечает его как stale-cache с возрастом — интерфейс может написать «курс обновлён 12 минут назад».

src/rates-client.ts
import { CircuitBreaker } from "./circuit-breaker.ts";
import { CircuitOpenError, UpstreamError } from "./errors.ts";
import { fetchWithRetry, type RetryOptions } from "./fetch-with-retry.ts";
import { RateLimiter } from "./rate-limiter.ts";

export type Rates = { base: string; rates: Record<string, number> };
export type RatesResult = { data: Rates; source: "live" | "stale-cache"; ageMs: number };

type RatesClientOptions = {
  baseUrl: string;
  maxStaleMs?: number; // how old a cached answer may be during an outage
  retry?: RetryOptions;
  breaker?: CircuitBreaker;
  limiter?: RateLimiter;
  now?: () => number;
};

export function createRatesClient({
  baseUrl,
  maxStaleMs = 6 * 60 * 60_000,
  retry = {},
  breaker = new CircuitBreaker({ name: "rates-api" }),
  limiter = new RateLimiter({ capacity: 5, ratePerSecond: 5 }),
  now = Date.now,
}: RatesClientOptions) {
  let cache: { data: Rates; savedAt: number } | undefined;

  async function getRates(signal?: AbortSignal): Promise<RatesResult> {
    try {
      const data = await breaker.run(async () => {
        await limiter.acquire(signal);
        const res = await fetchWithRetry(new URL("/rates", baseUrl), { headers: { accept: "application/json" } }, { ...retry, signal });
        // A 4xx here is our bug or a wrong key: fail loudly, do not hide it behind the cache
        if (!res.ok) throw new Error(`Rates API rejected the request: ${res.status}`);
        return (await res.json()) as Rates;
      });
      cache = { data, savedAt: now() };
      return { data, source: "live", ageMs: 0 };
    } catch (error) {
      const outage = error instanceof UpstreamError || error instanceof CircuitOpenError;
      if (outage && cache && now() - cache.savedAt <= maxStaleMs) {
        return { data: cache.data, source: "stale-cache", ageMs: now() - cache.savedAt };
      }
      throw error;
    }
  }

  return { getRates, breaker };
}
Клиент курсов: лимитер, предохранитель, повторы и ответ из кэша при отказе

Проверка: капризный сервер, тесты и журнал вызовов

Проверять такую обёртку на настоящем API неудобно: сбои не воспроизвести по заказу. Поднимем локальный «капризный» сервер, который отвечает по сценарию: заданный код и заголовки, зависание или обрыв соединения. Тот же сервер используют и демонстрация, и тесты.

src/flaky-server.ts
// A local "capricious" API for tests and demos: answers follow a script
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";

export type Step =
  | { status: number; headers?: Record<string, string>; body?: unknown }
  | "hang" // never answers
  | "drop"; // closes the connection without a response

export async function startFlakyServer() {
  const scripts = new Map<string, Step[]>();
  const requests: { path: string; method: string; idempotencyKey: string | undefined }[] = [];

  const server = createServer((req, res) => {
    const path = new URL(req.url ?? "/", "http://localhost").pathname;
    const key = req.headers["idempotency-key"];
    requests.push({ path, method: req.method ?? "GET", idempotencyKey: typeof key === "string" ? key : undefined });
    const step = scripts.get(path)?.shift() ?? { status: 200, body: { ok: true } };
    if (step === "hang") return;
    if (step === "drop") return void req.socket.destroy();
    res.writeHead(step.status, { "content-type": "application/json", ...step.headers });
    res.end(JSON.stringify(step.body ?? {}));
  });

  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const { port } = server.address() as AddressInfo;

  return {
    url: `http://127.0.0.1:${port}`,
    script(path: string, steps: Step[]) {
      scripts.set(path, [...steps]);
    },
    hits(path: string) {
      return requests.filter((r) => r.path === path);
    },
    async close() {
      server.closeAllConnections();
      await new Promise((resolve) => server.close(resolve));
    },
  };
}
Локальный капризный API: сценарий ответов для каждого пути, журнал запросов
src/demo.ts
import { UpstreamError } from "./errors.ts";
import { fetchWithRetry } from "./fetch-with-retry.ts";
import { startFlakyServer } from "./flaky-server.ts";

const api = await startFlakyServer();
const log = (entry: object) => console.log(JSON.stringify(entry));

// GET: 503, a dropped connection, 429 with Retry-After, then success
api.script("/rates", [
  { status: 503 },
  "drop",
  { status: 429, headers: { "retry-after": "1" } },
  { status: 200, body: { base: "KZT", rates: { USD: 0.0019 } } },
]);
const res = await fetchWithRetry(`${api.url}/rates`, {}, { log });
console.log("result:", res.status, await res.json());

// POST without an idempotency key: no retries, the error goes to the caller
api.script("/orders", [{ status: 503 }]);
try {
  await fetchWithRetry(`${api.url}/orders`, { method: "POST", body: "{}" }, { log });
} catch (error) {
  if (!(error instanceof UpstreamError)) throw error;
  console.log("error:", error.kind, error.status, `attempts=${error.attempts}`);
}

await api.close();
Демонстрация: GET переживает 503, обрыв и 429; POST без ключа не повторяется
terminal
$ node src/demo.ts
{"method":"GET","url":"http://127.0.0.1:57524/rates","attempt":1,"outcome":503,"ms":12,"next":"retry","delayMs":163}
{"method":"GET","url":"http://127.0.0.1:57524/rates","attempt":2,"outcome":"network","ms":23,"next":"retry","delayMs":342}
{"method":"GET","url":"http://127.0.0.1:57524/rates","attempt":3,"outcome":429,"ms":2,"next":"retry","delayMs":1000}
{"method":"GET","url":"http://127.0.0.1:57524/rates","attempt":4,"outcome":200,"ms":16,"next":"done"}
result: 200 { base: 'KZT', rates: { USD: 0.0019 } }
{"method":"POST","url":"http://127.0.0.1:57524/orders","attempt":1,"outcome":503,"ms":4,"next":"give-up"}
error: http 503 attempts=1
Реальный вывод на Node.js 24.18: паузы 163 и 342 мс — Full Jitter, пауза 1000 мс взята из Retry-After; POST сдался после первой попытки

Журнал в демонстрации — та самая наблюдаемость: на каждую попытку одна структурированная запись с методом, адресом, номером попытки, исходом, длительностью и решением. Из таких записей собирают метрики: долю повторов к числу вызовов, распределение исходов (503, 429, timeout, network), перцентили длительности внешних вызовов и переключения предохранителя — последнее стоит превратить в алерт. В журнал не пишут заголовок Authorization, ключи API и персональные данные из тел запросов; подробнее о структурированных логах и метриках — в материале о мониторинге.

test/fetch-with-retry.test.ts
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { UpstreamError } from "../src/errors.ts";
import { backoffDelay, fetchWithRetry, parseRetryAfter, type RetryOptions } from "../src/fetch-with-retry.ts";
import { startFlakyServer } from "../src/flaky-server.ts";

let api: Awaited<ReturnType<typeof startFlakyServer>>;
before(async () => (api = await startFlakyServer()));
after(() => api.close());

// Pauses are recorded instead of waited for: tests stay fast and deterministic
function fast(extra: RetryOptions = {}) {
  const delays: number[] = [];
  const options: RetryOptions = {
    sleep: async (ms) => void delays.push(ms),
    random: () => 1, // upper bound of the jitter range
    ...extra,
  };
  return { delays, options };
}

describe("fetchWithRetry", () => {
  it("retries 5xx with exponential delays and returns the success", async () => {
    api.script("/a", [{ status: 503 }, { status: 502 }, { status: 200, body: { ok: 1 } }]);
    const { delays, options } = fast();
    const res = await fetchWithRetry(`${api.url}/a`, {}, options);
    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), { ok: 1 });
    assert.equal(api.hits("/a").length, 3);
    assert.deepEqual(delays, [200, 400]);
  });

  it("does not retry a 4xx and returns it to the caller", async () => {
    api.script("/b", [{ status: 400, body: { error: "bad" } }]);
    const res = await fetchWithRetry(`${api.url}/b`, {}, fast().options);
    assert.equal(res.status, 400);
    assert.equal(api.hits("/b").length, 1);
  });

  it("waits as long as Retry-After says on 429", async () => {
    api.script("/c", [{ status: 429, headers: { "retry-after": "3" } }, { status: 200 }]);
    const { delays, options } = fast();
    const res = await fetchWithRetry(`${api.url}/c`, {}, options);
    assert.equal(res.status, 200);
    assert.deepEqual(delays, [3000]);
  });

  it("gives up at once when Retry-After is longer than allowed", async () => {
    api.script("/d", [{ status: 503, headers: { "retry-after": "120" } }]);
    await assert.rejects(fetchWithRetry(`${api.url}/d`, {}, fast().options), (error: unknown) => {
      assert.ok(error instanceof UpstreamError);
      assert.equal(error.status, 503);
      assert.equal(error.retryAfterMs, 120_000); // the caller can reschedule the job
      return true;
    });
    assert.equal(api.hits("/d").length, 1);
  });

  it("aborts a hanging attempt by timeout and retries", async () => {
    api.script("/e", ["hang", { status: 200 }]);
    const entries: string[] = [];
    const { options } = fast({ attemptTimeoutMs: 100, log: (e) => entries.push(`${e.outcome}:${e.next}`) });
    const res = await fetchWithRetry(`${api.url}/e`, {}, options);
    assert.equal(res.status, 200);
    assert.deepEqual(entries, ["timeout:retry", "200:done"]);
  });

  it("retries a dropped connection", async () => {
    api.script("/f", ["drop", { status: 200 }]);
    const entries: string[] = [];
    const res = await fetchWithRetry(`${api.url}/f`, {}, fast({ log: (e) => entries.push(String(e.outcome)) }).options);
    assert.equal(res.status, 200);
    assert.deepEqual(entries, ["network", "200"]);
  });

  it("does not retry POST without an idempotency key", async () => {
    api.script("/g", [{ status: 503 }, { status: 201 }]);
    await assert.rejects(
      fetchWithRetry(`${api.url}/g`, { method: "POST", body: "{}" }, fast().options),
      (error: unknown) => error instanceof UpstreamError && error.attempts === 1,
    );
    assert.equal(api.hits("/g").length, 1);
  });

  it("retries POST with an idempotency key, sending the same key", async () => {
    api.script("/h", [{ status: 503 }, { status: 201 }]);
    const init = { method: "POST", body: "{}", headers: { "idempotency-key": "order-1042-payment" } };
    const res = await fetchWithRetry(`${api.url}/h`, init, fast().options);
    assert.equal(res.status, 201);
    assert.deepEqual(api.hits("/h").map((r) => r.idempotencyKey), ["order-1042-payment", "order-1042-payment"]);
  });

  it("stops at the total deadline even if attempts remain", async () => {
    api.script("/i", ["hang", "hang", "hang", "hang"]);
    const started = performance.now();
    await assert.rejects(
      fetchWithRetry(`${api.url}/i`, {}, { attemptTimeoutMs: 100, totalTimeoutMs: 250, baseDelayMs: 10 }),
      (error: unknown) => error instanceof UpstreamError && error.kind === "deadline",
    );
    assert.ok(performance.now() - started < 400);
  });

  it("does not retry when the caller cancels", async () => {
    api.script("/j", ["hang", { status: 200 }]);
    const controller = new AbortController();
    setTimeout(() => controller.abort(), 50);
    await assert.rejects(
      fetchWithRetry(`${api.url}/j`, {}, { signal: controller.signal }),
      (error: unknown) => error instanceof DOMException && error.name === "AbortError",
    );
    assert.equal(api.hits("/j").length, 1);
  });
});

describe("helpers", () => {
  it("parses Retry-After as seconds and as an HTTP date", () => {
    const now = Date.parse("2026-10-11T12:00:00Z");
    assert.equal(parseRetryAfter("120", now), 120_000);
    assert.equal(parseRetryAfter("Sun, 11 Oct 2026 12:00:30 GMT", now), 30_000);
    assert.equal(parseRetryAfter("soon", now), undefined);
    assert.equal(parseRetryAfter(null, now), undefined);
  });

  it("keeps full jitter within [0, min(cap, base * 2^n)]", () => {
    assert.equal(backoffDelay(0, 200, 5_000, () => 0), 0);
    assert.equal(backoffDelay(3, 200, 5_000, () => 1), 1_600);
    assert.equal(backoffDelay(10, 200, 5_000, () => 1), 5_000);
    assert.equal(backoffDelay(2, 200, 5_000, () => 0.5), 400);
  });
});
12 тестов обёртки: 5xx, 4xx, 429 и Retry-After, зависание, обрыв, POST с ключом и без, общий срок, отмена, парсинг и jitter
test/resilience.test.ts
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { CircuitBreaker } from "../src/circuit-breaker.ts";
import { CircuitOpenError } from "../src/errors.ts";
import { startFlakyServer } from "../src/flaky-server.ts";
import { RateLimiter } from "../src/rate-limiter.ts";
import { createRatesClient } from "../src/rates-client.ts";

let api: Awaited<ReturnType<typeof startFlakyServer>>;
before(async () => (api = await startFlakyServer()));
after(() => api.close());

const noRetries = { retries: 0 };
const rates = { base: "KZT", rates: { USD: 0.0019 } };

describe("circuit breaker", () => {
  it("opens after N outages, fails fast, then closes after a successful trial", async () => {
    let clock = 0;
    const transitions: string[] = [];
    const breaker = new CircuitBreaker({
      name: "rates-api", failureThreshold: 3, openMs: 30_000, now: () => clock,
      onStateChange: (from, to) => transitions.push(`${from}->${to}`),
    });
    const client = createRatesClient({ baseUrl: api.url, breaker, retry: noRetries });
    api.script("/rates", [{ status: 503 }, { status: 503 }, { status: 503 }, { status: 200, body: rates }]);

    for (let i = 0; i < 3; i++) await assert.rejects(client.getRates());
    assert.equal(breaker.state, "open");
    await assert.rejects(client.getRates(), CircuitOpenError);
    assert.equal(api.hits("/rates").length, 3); // the 4th call never reached the server

    clock += 30_000; // the open period is over: one trial call goes through
    const result = await client.getRates();
    assert.equal(result.source, "live");
    assert.deepEqual(transitions, ["closed->open", "open->half-open", "half-open->closed"]);
  });

  it("does not count our own 4xx as an outage", async () => {
    const breaker = new CircuitBreaker({ name: "strict", failureThreshold: 1 });
    const client = createRatesClient({ baseUrl: api.url, breaker, retry: noRetries });
    api.script("/rates", [{ status: 401 }]);
    await assert.rejects(client.getRates(), /rejected the request: 401/);
    assert.equal(breaker.state, "closed");
  });
});

describe("degradation", () => {
  it("serves the last good answer from the cache during an outage", async () => {
    let clock = 1_000_000;
    const client = createRatesClient({ baseUrl: api.url, retry: noRetries, now: () => clock });
    api.script("/rates", [{ status: 200, body: rates }, { status: 503 }]);
    assert.equal((await client.getRates()).source, "live");

    clock += 60_000;
    const degraded = await client.getRates();
    assert.equal(degraded.source, "stale-cache");
    assert.equal(degraded.ageMs, 60_000);
    assert.deepEqual(degraded.data, rates);
  });

  it("fails when the cache is too old", async () => {
    let clock = 0;
    const client = createRatesClient({ baseUrl: api.url, retry: noRetries, maxStaleMs: 1_000, now: () => clock });
    api.script("/rates", [{ status: 200, body: rates }, { status: 503 }]);
    await client.getRates();
    clock += 5_000;
    await assert.rejects(client.getRates(), /answered 503/);
  });
});

describe("rate limiter", () => {
  it("lets a burst through and then spaces requests out", async () => {
    const limiter = new RateLimiter({ capacity: 2, ratePerSecond: 10 });
    const started = performance.now();
    await limiter.acquire();
    await limiter.acquire();
    assert.ok(performance.now() - started < 20, "the burst is immediate");
    await limiter.acquire();
    assert.ok(performance.now() - started >= 90, "the third request waits ~100 ms");
  });
});
5 тестов: размыкание и восстановление предохранителя, 4xx не размыкает, ответ из кэша, слишком старый кэш, лимитер
terminal

> resilient-fetch@1.0.0 test
> node --test

▶ fetchWithRetry
  ✔ retries 5xx with exponential delays and returns the success (17.6958ms)
  ✔ does not retry a 4xx and returns it to the caller (1.1994ms)
  ✔ waits as long as Retry-After says on 429 (14.2756ms)
  ✔ gives up at once when Retry-After is longer than allowed (13.248ms)
  ✔ aborts a hanging attempt by timeout and retries (120.7663ms)
  ✔ retries a dropped connection (18.6495ms)
  ✔ does not retry POST without an idempotency key (3.393ms)
  ✔ retries POST with an idempotency key, sending the same key (12.1761ms)
  ✔ stops at the total deadline even if attempts remain (262.2019ms)
  ✔ does not retry when the caller cancels (63.5269ms)
✔ fetchWithRetry (532.1418ms)
▶ helpers
  ✔ parses Retry-After as seconds and as an HTTP date (0.2605ms)
  ✔ keeps full jitter within [0, min(cap, base * 2^n)] (0.1896ms)
✔ helpers (0.6801ms)
▶ circuit breaker
  ✔ opens after N outages, fails fast, then closes after a successful trial (39.2041ms)
  ✔ does not count our own 4xx as an outage (2.3658ms)
✔ circuit breaker (45.7581ms)
▶ degradation
  ✔ serves the last good answer from the cache during an outage (10.3849ms)
  ✔ fails when the cache is too old (31.4225ms)
✔ degradation (42.0438ms)
▶ rate limiter
  ✔ lets a burst through and then spaces requests out (108.3147ms)
✔ rate limiter (108.5081ms)
ℹ tests 17
ℹ suites 5
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 670.8636
Реальный вывод npm test на Node.js 24.18
terminal
$ npm run typecheck

> resilient-fetch@1.0.0 typecheck
> tsc
Проверка типов TypeScript 7.0.2 прошла без ошибок

Тесты проверены «от обратного»: если убрать условие идемпотентности, падает тест про POST без ключа, а если игнорировать Retry-After — тест про 429. Тест зависания ждёт настоящий таймаут 100 мс, тест общего срока — 250 мс, остальные паузы подменены записью в массив.

  • fetch без таймаута: зависший сервис держит запросы и соединения
  • Повтор всего подряд, включая 400 и 401
  • Автоматический повтор POST без ключа идемпотентности — двойные платежи и заявки
  • Повторы без паузы или с одинаковой паузой у всех клиентов, без jitter
  • Повторы на каждом слое: HTTP-клиент, сервис, очередь — нагрузка умножается
  • Retry-After проигнорирован, а лимит провайдера не учтён заранее
  • Предохранитель размыкается от наших собственных 4xx
  • Устаревшие данные из кэша показаны там, где важна точность: платежи, остатки
Как проверено3 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    resilient-fetch: src/errors.ts, fetch-with-retry.ts, circuit-breaker.ts, rate-limiter.ts, rates-client.ts, flaky-server.ts

    Временный проект Node.js 24.18 + TypeScript 7.0.2 + @types/node 24.19: tsc без ошибок (проверено, что tsc ловит намеренную ошибку типов); npm test (node:test, .ts через type stripping) — 17/17 pass против локального HTTP-сервера, прогон трижды стабилен; мутации: без условия идемпотентности падает тест POST, без Retry-After — тест 429

  • запущено, работает

    src/demo.ts

    node src/demo.ts — вывод в статье фактический (паузы jitter случайны, 1000 мс из Retry-After)

  • запущено, работает

    test/*.test.ts

    Вывод npm test в статье — фактический

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.