Каждый вызов чужого API нужно ограничить таймаутом на попытку и на всю операцию, повторять только временные сбои (сеть, таймаут, 408, 429, 5xx) и только для идемпотентных запросов, а паузы между попытками делать экспоненциальными со случайным разбросом (jitter) и с учётом заголовка Retry-After. Если сервис лежит долго, circuit breaker перестаёт его вызывать, а приложение отвечает из кэша или честной заглушкой, не роняя свои страницы.
После материала вы сможете
- задать таймаут на попытку и общий срок через
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 г.
Содержание
Чужой 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 и деградация: кэш или заглушка
- 01Сервис приложенияКэш и деградация: что показать, если ответа нет
- 02Circuit breakerНе вызывать сервис, который лежит
- 03Rate limiterНе превышать лимит провайдера
- 04fetchWithRetryТаймауты, классификация ошибок, backoff, Retry-After
- 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 со схемами ответов разобран в отдельном рецепте.
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{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}Таймауты: на попытку и на всю операцию
Таймаутов нужно два. Таймаут попытки обрывает один зависший запрос и даёт шанс повтору. Общий срок (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 о нашем собственном запросе не должен ни открывать предохранитель, ни прятаться за старым кэшем.
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 мс, а фактическая пауза — случайное число от нуля до этой границы.
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
}
}
}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 оно сообщает, сколько ещё сервис будет недоступен.
Есть Retry-After
Ждём ровно столько, сколько просит сервер, вместо своей экспоненты.
parseRetryAfterпонимает оба формата.Retry-After слишком большой
Если ожидание длиннее
maxRetryAfterMs(по умолчанию 30 секунд) или не укладывается в общий срок — сдаёмся сразу и отдаём значение вUpstreamError.retryAfterMs. Фоновая задача может перепланировать себя на это время.Не доводить до 429
Ограничьте свой темп заранее: если провайдер разрешает 5 запросов в секунду, пусть клиент сам не отправляет больше.
Для собственного ограничения подходит маркерная корзина (token bucket): в корзине до capacity маркеров, они пополняются со скоростью ratePerSecond, каждый запрос забирает один. Короткий всплеск проходит сразу, длинный поток выравнивается до заданного темпа. AWS предлагает тот же механизм и для бюджета повторов: локально ограничивать, сколько повторов клиент вообще может себе позволить.
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 });
}
}
}Circuit breaker и деградация
Повторы рассчитаны на короткие сбои. Если сервис лежит полчаса, каждый запрос всё равно отработает все попытки и таймауты, держа соединения и время пользователей. Паттерн circuit breaker (предохранитель) из Azure Architecture Center решает это так: после серии отказов предохранитель «размыкается» и сразу отвечает ошибкой, не вызывая сервис. Документация подчёркивает разницу: Retry рассчитывает, что операция в итоге пройдёт, а Circuit Breaker не даёт выполнять операцию, которая скорее всего упадёт. Сочетают их так: повторы идут через предохранитель.
Closed (замкнут)
- Что происходит
- Вызовы идут в сервис, отказы считаются
- Переход
- Порог отказов достигнут → Open
Open (разомкнут)
- Что происходит
- Вызов сразу завершается
CircuitOpenError, сервис не трогаем - Переход
- Прошло
openMs→ Half-Open
Half-Open (полуоткрыт)
- Что происходит
- Пропускается пробный вызов
- Переход
- Успех → Closed; отказ → снова Open
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 минут назад».
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 неудобно: сбои не воспроизвести по заказу. Поднимем локальный «капризный» сервер, который отвечает по сценарию: заданный код и заголовки, зависание или обрыв соединения. Тот же сервер используют и демонстрация, и тесты.
// 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));
},
};
}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();$ 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Журнал в демонстрации — та самая наблюдаемость: на каждую попытку одна структурированная запись с методом, адресом, номером попытки, исходом, длительностью и решением. Из таких записей собирают метрики: долю повторов к числу вызовов, распределение исходов (503, 429, timeout, network), перцентили длительности внешних вызовов и переключения предохранителя — последнее стоит превратить в алерт. В журнал не пишут заголовок Authorization, ключи API и персональные данные из тел запросов; подробнее о структурированных логах и метриках — в материале о мониторинге.
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);
});
});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");
});
});
> 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 run typecheck
> resilient-fetch@1.0.0 typecheck
> tscТесты проверены «от обратного»: если убрать условие идемпотентности, падает тест про 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.