Skip to content

REST-клиент

createRestClient

ts
createRestClient(config: HttpConfig): RestClient

Создаёт REST-клиент с расширенными HTTP-возможностями.

Методы

МетодОписание
get(url, config?)GET-запрос
post(url, data?, config?)POST-запрос
put(url, data?, config?)PUT-запрос
patch(url, data?, config?)PATCH-запрос
delete(url, config?)DELETE-запрос
request(url, config?)Универсальный запрос
cancellableRequest(key, url, config?)Запрос, отменяемый по ключу
cancelRequest(key)Отменить запрос по ключу
clearCache()Очистить весь кэш ответов клиента (async)
invalidateCache(matcher)Очистить только те записи кэша, чей URL совпадает с matcher (подстрока, RegExp или (info) => boolean); возвращает (Promise<number>) количество удалённых записей
getCircuitBreakerState()Promise<"closed" | "open" | "half-open" | null>null, если circuitBreaker не настроен. Резолвится синхронно (без реальной асинхронной работы), если не задан circuitBreaker.store
getQueuedRequests()Promise<QueuedRequest[]> — запросы, ожидающие следующего сброса офлайн-очереди (пусто, если offlineQueue не настроен)
flushQueue()Вручную попытаться отправить всё, что в очереди (также происходит автоматически при восстановлении связи); no-op, если offlineQueue не настроен

Опции HttpConfig

ОпцияОписание
baseURLБазовый URL для всех запросов
timeoutТаймаут запроса в мс
headersЗаголовки по умолчанию
withCredentialsВключать cookies
retry.attemptsКоличество повторных попыток
retry.delayMsБазовая задержка между попытками в мс
retry.backoffMultiplierМножитель экспоненциального backoff
retry.retriableStatusHTTP-коды статуса, при которых допустим повтор (например, [429, 500, 503])
retry.maxRetryAfterMsМаксимальное время ожидания по заголовку Retry-After в мс (по умолчанию: 60000)
retry.jitterStrategyАлгоритм jitter для backoff: "fixed" (по умолчанию), "full" или "decorrelated"
cache.enabledВключить кэширование ответов для GET-запросов
cache.ttlMsTTL кэша в мс
cache.strategy"strict" (по умолчанию) или "stale-while-revalidate"
cache.staleMsДополнительное время после ttlMs, в течение которого может отдаваться устаревший ответ (стратегия SWR)
cache.storeКастомный backend CacheStore (например, Redis) вместо встроенного in-memory TtlCache — см. Пользовательский backend кэша
rateLimit.maxConcurrentМаксимум одновременных запросов
rateLimit.maxRequestsPerIntervalМаксимум запросов за временное окно
rateLimit.intervalMsРазмер временного окна в мс
rateLimit.storeКастомный backend RateLimiterStore (например, Redis) для лимита, общего для нескольких серверных инстансов — см. Распределённое ограничение частоты запросов
rateLimit.keyИмя бакета при использовании общего rateLimit.store (по умолчанию: случайный id для каждого инстанса — без явного key у store нет эффекта общего использования)
rateLimit.leaseMsАвтоматическое истечение (мс) для слота конкурентности на базе store, если его владелец упал без освобождения (по умолчанию: 30000)
rateLimit.onRateLimitHeadersКоллбэк с сырыми заголовками ответа для каждого запроса (успешного или с ошибкой) — проактивный троттлинг по заголовкам X-RateLimit-*/аналогичным вместо реакции только на 429. См. Проактивное ограничение скорости
metrics.onRequestStartКоллбэк при старте запроса
metrics.onRequestEndКоллбэк по завершении запроса (включает длительность и количество байт)
auth.getTokenАсинхронная функция, возвращающая Bearer-токен (вызывается перед каждым запросом, если не задан auth.tokenTtlMs)
auth.onUnauthorizedОпциональный асинхронный коллбэк на 401 — здесь обновляйте токен; запрос повторяется один раз
auth.tokenTtlMsКэшировать результат getToken() на это количество мс вместо вызова перед каждым запросом; автоматически сбрасывается при 401
sanitizeHeadersМаскировать чувствительные заголовки в коллбэках метрик (по умолчанию: true — безопасно по умолчанию)
sensitiveHeadersДополнительные заголовки для маскирования (расширяет DEFAULT_SENSITIVE_HEADERS)
adapterКастомный HTTP-адаптер (например, нативный fetch) — заменяет встроенный axios
circuitBreakerСм. Circuit breaker{ failureThreshold, openMs, successThreshold?, isFailure?, store?, key? }
tracing.generateTraceparentДобавлять заголовок W3C traceparent к каждому запросу (по умолчанию: false) — см. Трассировка запросов
tracing.providerХук TracingProvider, создающий span на каждый запрос — см. Трассировка запросов
idempotencyHeaderNameИмя заголовка, используемое для RestRequestConfig.idempotencyKey (по умолчанию: "Idempotency-Key") — см. Ключи идемпотентности
autoIdempotencyKeyЗаставить RequestExecutor автоматически генерировать ключ идемпотентности на логический запрос (по умолчанию: false) — см. Ключи идемпотентности
offlineQueueСтавить в очередь изменяющие запросы, сделанные в офлайне, и повторять их после восстановления связи — { enabled, persistAdapter, isOnline?, onOnlineChange?, shouldQueue?, maxQueueSize?, onFlushSuccess?, onFlushError? }. См. Очередь офлайн-запросов

Переопределение кэша для отдельного запроса

js
const res = await client.get('/data', {
  useCache: true,
  cacheTtlMs: 30000,
  cacheKey: 'my-custom-key',
})

Загрузка файлов и прогресс

RestRequestConfig расширяет собственный AxiosRequestConfig из axios, поэтому data может быть FormData/Blob/ArrayBuffer, а onUploadProgress/onDownloadProgress уже типизированы и подключены — никакой дополнительной настройки для транспорта по умолчанию (axios) не требуется:

js
const formData = new FormData()
formData.append('file', fileInput.files[0])

await client.post('/upload', formData, {
  onUploadProgress: (event) => {
    const percent = event.total ? Math.round((event.loaded / event.total) * 100) : 0
    console.log(`Uploaded ${percent}%`)
  },
})

await client.get('/large-report.csv', {
  responseType: 'blob',
  onDownloadProgress: (event) => console.log(event.loaded, 'bytes received'),
})

При использовании кастомного adapter (см. HTTP-адаптер) вместо встроенного транспорта axios onUploadProgress/onDownloadProgress всё равно передаются в объект config вашего адаптера как есть, но за их фактический вызов отвечает сам адаптер — у fetch нет нативного события прогресса загрузки, поэтому адаптеру на fetch для этого понадобится читатель ReadableStream (либо XMLHttpRequest). См. examples/file-upload.ts.

Точечная инвалидация кэша

clearCache() стирает весь кэш ответов целиком. Чтобы инвалидировать только записи, затронутые мутацией (например, после POST/PUT/DELETE), используйте вместо этого invalidateCache() — она принимает подстроку, RegExp или предикат над { method, url } и возвращает количество удалённых записей. Оба метода async (чтобы кастомный cache.store мог быть на реальном сетевом вызове):

js
await client.post('/users/1/orders', newOrder)

await client.invalidateCache('/users/1') // совпадение по подстроке в кэшированном URL
await client.invalidateCache(/^https:\/\/api\.example\.com\/users\/\d+$/)
await client.invalidateCache(({ method, url }) => method === 'GET' && url.includes('/orders'))

Пользовательский backend кэша (CacheStore)

По умолчанию cache.enabled: true кэширует ответы в in-memory TtlCache, привязанном к конкретному экземпляру клиента — нормально для браузерного SPA, но в развёртывании с несколькими инстансами у каждого серверного процесса свой холодный кэш. Передайте cache.store, чтобы использовать любой backend, реализующий CacheStore — например, Redis, чтобы кэшированные ответы были общими для всех инстансов:

ts
import { createRestClient, type CacheStore, type ApiResponse } from 'rest-pipeline-js'

const redisStore: CacheStore<ApiResponse<unknown>> = {
  async get(key) {
    const raw = await redis.get(key)
    return raw ? JSON.parse(raw) : undefined
  },
  async set(key, value, ttlMs) {
    await redis.set(key, JSON.stringify(value), 'PX', ttlMs)
  },
  async delete(key) {
    await redis.del(key)
  },
  async clear() {
    await redis.flushdb()
  },
  // getStale/deleteWhere опциональны — без них стратегия
  // 'stale-while-revalidate' и invalidateCache() корректно
  // деградируют (см. JSDoc для CacheStore) вместо выброса исключения.
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  cache: { enabled: true, ttlMs: 60_000, store: redisStore },
})

Полную аннотированную версию см. в examples/redis-cache-store.ts.

Полный пример

js
import { createRestClient } from 'rest-pipeline-js'

const client = createRestClient({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  retry: {
    attempts: 2,
    delayMs: 500,
    backoffMultiplier: 2,
    retriableStatus: [429, 500, 503],
  },
  cache: { enabled: true, ttlMs: 60000 },
  rateLimit: { maxConcurrent: 3, maxRequestsPerInterval: 10, intervalMs: 1000 },
  auth: {
    getToken: async () => localStorage.getItem('token') ?? '',
    onUnauthorized: async () => {
      /* здесь обновляем токен */
    },
  },
  sanitizeHeaders: true,
})

const res = await client.get('/users/1')
console.log(res.data)

// Поддержка PATCH
await client.patch('/users/1', { name: 'Alice' })

// Отменяемый запрос
const req = client.cancellableRequest('my-key', '/search', {
  params: { q: 'foo' },
})
// Отменить в любой момент:
client.cancelRequest('my-key')

Распределённое ограничение частоты запросов (RateLimiterStore)

По умолчанию rateLimit применяется in-memory, в рамках одного экземпляра клиента — нормально для браузерного SPA, но в развёртывании с несколькими инстансами каждый серверный процесс применяет свой собственный лимит, так что N инстансов фактически допускают N-кратный настроенный лимит. Передайте rateLimit.store, чтобы разделить лимит между инстансами (например, через Redis):

ts
import { createRestClient, type RateLimiterStore } from 'rest-pipeline-js'

const redisRateLimiterStore: RateLimiterStore = {
  async incrementWindow(key, intervalMs) {
    const count = await redis.incr(key)
    if (count === 1) await redis.pexpire(key, intervalMs)
    return count
  },
  async acquireConcurrencySlot(key, maxConcurrent, leaseMs) {
    // Нужен атомарный increment-if-below-cap (обычно небольшой Lua-скрипт) —
    // полный набросок см. в examples/redis-rate-limiter-store.ts.
    // ...
  },
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  rateLimit: {
    maxRequestsPerInterval: 100,
    intervalMs: 60_000,
    store: redisRateLimiterStore,
    key: 'api-example-com', // общее имя бакета для всех инстансов
  },
})

Заметки:

  • Без явного key каждый экземпляр RateLimiter получает свой случайный ключ — store даёт эффект разделения только тогда, когда несколько лимитеров (в разных процессах) используют один и тот же key.
  • incrementWindow — это счётчик с фиксированным окном (fixed-window) — у него стандартная особенность всплеска на границе окна, свойственная любому fixed-window rate limiter'у (в отличие от sliding log). Это осознанный компромисс в пользу простоты; реализуйте sliding-window хранилище сами, если нужны более строгие границы.
  • acquireConcurrencySlot (maxConcurrent) невозможно сделать абсолютно точным между процессами без центрального сервиса блокировок — считайте его приблизительным ограничением, как и большинство распределённых семафоров на практике. leaseMs ограничивает, как долго слот удерживается, если его владелец упал без освобождения.

Полную аннотированную версию см. в examples/redis-rate-limiter-store.ts.

Проактивное ограничение скорости по заголовкам ответа rate-limit

По умолчанию ограничитель частоты реагирует только после провала запроса (429 + Retry-After или срабатывание circuit breaker'а). Многие API также сообщают, насколько близко вы к лимиту, в каждом ответе — X-RateLimit-Remaining, черновой заголовок IETF RateLimit-Remaining или заголовок конкретного вендора. rateLimit.onRateLimitHeaders позволяет читать их и проактивно тормозить запросы ещё до получения 429:

ts
const client = createRestClient({
  baseURL: 'https://api.example.com',
  rateLimit: {
    onRateLimitHeaders: (headers, control) => {
      const remaining = Number(headers['x-ratelimit-remaining'])
      const resetSec = Number(headers['x-ratelimit-reset'])
      if (remaining === 0 && Number.isFinite(resetSec)) {
        control.throttleFor(resetSec * 1000)
      }
    },
  },
})

Заметки:

  • Коллбэк выполняется после каждого ответа — как успешного, так и с ошибкой (ответ 429 обычно несёт те же заголовки, что и обычный). Сама библиотека не разбирает какой-либо конкретный формат заголовков — единого стандарта нет, вы читаете то, что присылает ваш backend.
  • control.throttleFor(ms) задерживает следующий вызов(ы) acquire() минимум на ms — это дополняет maxConcurrent/maxRequestsPerInterval, а не заменяет их, и также применяется при настроенном распределённом rateLimit.store (ожидание троттлинга происходит локально, перед делегированием в store).
  • Более поздний, но более короткий вызов throttleFor() не сокращает уже запланированное более длинное ожидание — побеждает максимум.

Ключи идемпотентности

Отправляйте заголовок Idempotency-Key на изменяющих запросах (POST/PUT/PATCH/DELETE), чтобы backend, поддерживающий ключи идемпотентности (Stripe, PayPal и множество внутренних API), мог безопасно дедуплицировать повторённые запросы вместо их двойного применения. Библиотека только отправляет заголовок — дедупликация остаётся задачей backend'а.

js
// Вручную: генерируем ключ один раз на логическую операцию, переиспользуем на всех попытках
const idempotencyKey = crypto.randomUUID()
await client.post('/orders', cart, { idempotencyKey })

// Кастомное имя заголовка
const client = createRestClient({ idempotencyHeaderName: 'X-Idempotency-Key' })

RequestExecutor (класс, который на самом деле реализует повторы — см. RequestExecutor; собственные client.post()/и т. д. у createRestClient() сами по себе не повторяют запрос) может сгенерировать ключ за вас автоматически:

js
import { RequestExecutor } from 'rest-pipeline-js'

const executor = new RequestExecutor({
  baseURL: 'https://api.example.com',
  autoIdempotencyKey: true, // генерирует один ключ на логический запрос, переиспользуемый на каждой попытке повтора
  retry: { attempts: 2, delayMs: 300, backoffMultiplier: 2 },
})

await executor.execute('/orders', { method: 'POST', data: { items: ['sku-1'] } })

autoIdempotencyKey затрагивает только изменяющие методы (POST/PUT/PATCH/DELETE) и генерирует ключ только если вызывающий код ещё не передал свой через idempotencyKey. См. examples/idempotent-mutations.ts.

Очередь офлайн-запросов

Ставьте изменяющие запросы (по умолчанию POST/PUT/PATCH/DELETE), сделанные в офлайне, в очередь вместо немедленного провала и повторяйте их — по порядку, с одним и тем же Idempotency-Key на каждой попытке повтора — как только связь восстановится:

js
import { createRestClient, OfflineQueuedError } from 'rest-pipeline-js'

const client = createRestClient({
  baseURL: 'https://api.example.com',
  offlineQueue: {
    enabled: true,
    // Переиспользует PipelineStateAdapter (см. PipelineConfig.options.persistAdapter) —
    // подходит любая пара save/load, например localStorage в браузере.
    persistAdapter: {
      save: (queue) => localStorage.setItem('offline-queue', JSON.stringify(queue)),
      load: () => JSON.parse(localStorage.getItem('offline-queue') ?? 'null'),
    },
    onFlushSuccess: (request, response) => console.log('synced', request.url, response.data),
    onFlushError: (request, error) => console.error('failed permanently', request.url, error),
  },
})

try {
  await client.post('/orders', cart)
} catch (err) {
  if (err instanceof OfflineQueuedError) {
    // Поставлено в очередь, а не сетевой сбой — err.queueId коррелирует
    // с последующим коллбэком onFlushSuccess/onFlushError.
    console.log('Order queued, will sync automatically:', err.queueId)
  } else {
    throw err
  }
}
  • shouldQueue — какие запросы ставятся в очередь; по умолчанию — изменяющие методы (POST/PUT/PATCH/DELETE). GET никогда не ставится в очередь по умолчанию (устаревшее чтение бесполезно «повторять» позже).
  • isOnline/onOnlineChange — по умолчанию используют navigator.onLine и браузерное событие "online". Передайте свои реализации для Node/React Native (например, NetInfo из React Native) — без onOnlineChange вне браузера ничто не запускает автоматический сброс очереди; вызывайте client.flushQueue() сами, когда знаете, что связь восстановлена.
  • client.getQueuedRequests() — текущее содержимое очереди, например для бейджа «N действий ожидают синхронизации».
  • client.flushQueue() — вручную попытаться отправить всё, что в очереди (также происходит автоматически при восстановлении связи).
  • Каждый поставленный в очередь запрос получает Idempotency-Key (переиспользуемый на каждой попытке повтора, генерируется один раз, если вызывающий код ещё не задал свой) — тот же механизм, что и в разделе Ключи идемпотентности выше, так что backend, который его учитывает, не применит дважды мутацию, которая на самом деле прошла прямо перед потерей связи.
  • flush() пытается выполнить каждый запрос из очереди один раз за вызов, а не в цикле с backoff — запрос, провалившийся с настоящей HTTP-ошибкой (есть status), удаляется из очереди и сообщается через onFlushError; запрос вообще без status (неотличимый от «всё ещё офлайн») остаётся в очереди и повторяется при следующем сбросе. За повтор/backoff на уровне отдельной попытки отвечают retry/jitterStrategy у RequestExecutor — сброс очереди является более грубым циклом повтора, запускаемым событиями восстановления связи, а не плотным циклом повтора против, возможно, ещё восстанавливающегося backend'а.

Полную аннотированную версию см. в examples/offline-queue.ts.

Трассировка запросов

Две независимые возможности:

tracing.generateTraceparent добавляет заголовок W3C Trace Context traceparent к каждому запросу (пропускается, если запрос уже явно задаёт его), чтобы любой backend/APM, понимающий trace context, мог коррелировать вызов с остальной распределённой трассировкой:

js
const client = createRestClient({
  baseURL: 'https://api.example.com',
  tracing: { generateTraceparent: true },
})

Передайте traceId в запросе, чтобы коррелировать несколько вызовов под одной трассой вместо нового случайного значения каждый раз — runId (UUID) пайплайна без дефисов — это ровно те 32 hex-символа, которые нужны формату:

js
await client.get('/users/1', { traceId: orchestrator.getRunId().replace(/-/g, '') })

tracing.provider оборачивает каждый запрос в реальный span в вашей системе трассировки. Его форма (TracingProvider/TracingSpan) намеренно является подмножеством API Span из OpenTelemetry (duck-typing — этот пакет не зависит от @opentelemetry/api), так что настоящий OTel SDK подключается как тонкий адаптер:

ts
import { trace } from '@opentelemetry/api'
import { createRestClient, type TracingProvider } from 'rest-pipeline-js'

const tracer = trace.getTracer('my-app')
const otelProvider: TracingProvider = {
  startSpan: (name, attributes) => tracer.startSpan(name, { attributes }),
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  tracing: { generateTraceparent: true, provider: otelProvider },
})

startSpan(name, attributes) вызывается перед каждым запросом; span.end() — после; span.setStatus()/span.recordException() — при ошибке (оба опциональны в TracingSpan — минимальному провайдеру нужен только end()). Полную аннотированную версию, включая провайдер с консольным логированием без зависимостей, см. в examples/opentelemetry-tracing.ts.

Провайдер аутентификации

Автоматически добавляет заголовок Authorization: Bearer <token> перед каждым запросом. При ответе 401 вызывается onUnauthorized (например, для обновления токена), и запрос повторяется один раз — это предотвращает бесконечные циклы.

js
const client = createRestClient({
  baseURL: 'https://api.example.com',
  auth: {
    getToken: async () => {
      return localStorage.getItem('access_token') ?? ''
    },
    onUnauthorized: async () => {
      const newToken = await refreshAccessToken()
      localStorage.setItem('access_token', newToken)
    },
  },
})

// Authorization: Bearer <token> добавляется автоматически к каждому запросу
const res = await client.get('/profile')

Кэширование токена

Если getToken() — дорогая операция (например, обращение к защищённому хранилищу или эндпоинту обновления), задайте tokenTtlMs, чтобы переиспользовать результат между запросами вместо вызова getToken() перед каждым из них. Кэш автоматически сбрасывается при 401, поэтому следующий запрос всегда получает свежий токен перед повтором:

js
const client = createRestClient({
  baseURL: 'https://api.example.com',
  auth: {
    getToken: async () => requestTokenFromSecureEnclave(), // дорогая операция
    onUnauthorized: async () => refreshAccessToken(),
    tokenTtlMs: 5 * 60_000, // переиспользовать до 5 минут
  },
})

Санитизация логов

Маскирует чувствительные заголовки в коллбэках метрик (onRequestStart / onRequestEnd), чтобы они никогда не попадали в логи.

js
import { createRestClient, DEFAULT_SENSITIVE_HEADERS } from 'rest-pipeline-js'

// DEFAULT_SENSITIVE_HEADERS включает: authorization, x-api-key, x-auth-token,
// cookie, set-cookie, proxy-authorization

const client = createRestClient({
  baseURL: 'https://api.example.com',
  // sanitizeHeaders по умолчанию true — маскирование включено, если явно не отключить.
  sensitiveHeaders: ['x-internal-secret'], // расширяем список по умолчанию
  metrics: {
    onRequestStart: (info) => {
      // info.requestHeaders — чувствительные значения заменены на "REDACTED"
      console.log(info.requestHeaders)
    },
  },
})

// Чтобы увидеть сырые заголовки (например, только для локальной отладки), явно отключите:
// createRestClient({ ..., sanitizeHeaders: false });

Использование sanitizeHeadersMap напрямую:

js
import { sanitizeHeadersMap } from 'rest-pipeline-js'

const safe = sanitizeHeadersMap(
  { authorization: 'Bearer abc', 'content-type': 'application/json' },
  ['x-custom-secret'],
)
// { authorization: "REDACTED", "content-type": "application/json" }

RequestExecutor

Обёртка для REST-запросов с повторными попытками, таймаутом (через AbortController), поддержкой заголовка Retry-After и backoff'ом.

js
import { RequestExecutor } from 'rest-pipeline-js'

const executor = new RequestExecutor({
  baseURL: 'https://api.example.com',
  retry: {
    attempts: 3,
    delayMs: 500,
    backoffMultiplier: 2,
    retriableStatus: [429, 500, 502, 503],
    maxRetryAfterMs: 30000, // ограничить Retry-After 30 с
  },
})

// 5-й аргумент: внешний AbortSignal (например, из orchestrator.abort())
const res = await executor.execute('/data', undefined, 3, 5000, signal)

Когда сервер возвращает заголовок Retry-After (число секунд или HTTP-дата), эта задержка имеет приоритет над формулой backoff. Значения, превышающие maxRetryAfterMs, ограничиваются этим потолком. Таймаут обеспечивается через AbortController — отменяется сам HTTP-запрос, а не только промис.

Стратегии jitter

retry.jitterStrategy управляет тем, как случайность добавляется к вычисленной задержке backoff (на Retry-After, который всегда используется как есть, это не влияет):

  • "fixed" (по умолчанию) — delayMs * backoffMultiplier^(attempt-1) плюс до +10% случайного джиттера сверху. Обратно совместимо; задержка никогда не опускается ниже чистого значения backoff.
  • "full"delay = random(0, delayMs * backoffMultiplier^(attempt-1)). Лучше распределяет множество одновременных повторов (избегает синхронизированных «штормов» повторов, бьющих в backend в один и тот же момент), ценой того, что отдельные задержки иногда оказываются намного короче номинального backoff.
  • "decorrelated"delay = min(cap, random(delayMs, prevDelay * 3)), где prevDelay начинается с delayMs и обновляется после каждой попытки, а cap равен delayMs * backoffMultiplier^attempts. Распределяет одновременные повторы даже лучше, чем "full", поскольку следующая задержка каждого клиента зависит от его собственной предыдущей.

Оба алгоритма — из статьи AWS Exponential Backoff and Jitter — используйте их, когда против одного и того же backend одновременно могут повторять запросы много экземпляров вашего приложения.

js
const executor = new RequestExecutor({
  baseURL: 'https://api.example.com',
  retry: { attempts: 5, delayMs: 200, backoffMultiplier: 2, jitterStrategy: 'full' },
})

Circuit breaker

Защищает падающий backend (и ваше собственное приложение) от накопления повторов/таймаутов: после failureThreshold последовательных провалов клиент полностью перестаёт обращаться к сети на openMs и немедленно отклоняет запросы с CircuitOpenError (code: "CIRCUIT_OPEN"). После openMs он пропускает один пробный запрос (half-open); успех снова закрывает цепь, провал — открывает заново.

js
import { createRestClient, CircuitOpenError } from 'rest-pipeline-js'

const client = createRestClient({
  baseURL: 'https://api.example.com',
  circuitBreaker: {
    failureThreshold: 5, // открыть после 5 последовательных провалов
    openMs: 30_000, // оставаться открытой 30с перед следующей пробой
    successThreshold: 2, // нужно 2 успешных пробы для полного закрытия
    isFailure: (error) => error.status === undefined || error.status >= 500, // игнорировать 4xx
  },
})

try {
  await client.get('/flaky-endpoint')
} catch (err) {
  if (err instanceof CircuitOpenError) {
    // отклонено локально — сетевой вызов не выполнялся
  }
}

await client.getCircuitBreakerState() // "closed" | "open" | "half-open" — async
  • Работает поверх повторов, кэша, ограничения частоты, аутентификации и кастомных adapter'ов — он оборачивает сам сетевой вызов, как и эти возможности.
  • Каждая попытка повтора (из RequestExecutor/request.retry) засчитывается как отдельный проход через breaker, поэтому нестабильный эндпоинт с включёнными повторами открывает цепь быстрее, а не медленнее.
  • Отменённые/прерванные запросы никогда не засчитываются как провалы.
  • Не включён по умолчанию — без circuitBreaker поведение не меняется.
  • getCircuitBreakerState() (как и любой метод CircuitBreaker) является async — резолвится синхронно (без реальной асинхронной работы), если не задан circuitBreaker.store (см. ниже).

Распределённый circuit breaker (CircuitBreakerStore)

По умолчанию состояние circuit breaker'а живёт in-memory, в рамках одного экземпляра клиента — каждому серверному процессу нужны свои failureThreshold последовательных провалов, прежде чем он откроется, так что в развёртывании с несколькими инстансами проблемный backend поглощает в N раз больше провалов, чем настроено, прежде чем что-либо сработает. Передайте circuitBreaker.store, чтобы разделить состояние open/closed/half-open между инстансами:

ts
import { createRestClient, type CircuitBreakerStore } from 'rest-pipeline-js'

const redisCircuitBreakerStore: CircuitBreakerStore = {
  async get(key) {
    const raw = await redis.get(key)
    return raw ? JSON.parse(raw) : null
  },
  async set(key, state, ttlMs) {
    await redis.set(key, JSON.stringify(state), 'PX', ttlMs)
  },
  // Опционально: атомарный инкремент, избегает гонки get+compute+set между
  // конкурентными запросами на разных инстансах.
  async incrementCounter(key, field, ttlMs) {
    const n = await redis.incr(`${key}:${field}`)
    if (n === 1) await redis.pexpire(`${key}:${field}`, ttlMs)
    return n
  },
}

const client = createRestClient({
  baseURL: 'https://api.example.com',
  circuitBreaker: {
    failureThreshold: 5,
    openMs: 30_000,
    store: redisCircuitBreakerStore,
    key: 'api-example-com', // общее имя бакета для всех инстансов
  },
})

Как и в случае с rateLimit.key, именно явный key заставляет несколько экземпляров CircuitBreaker (в разных процессах) действительно разделять состояние — без него каждый получает свой случайный ключ. Без incrementCounter breaker переходит на схему get-compute-set, которая может недосчитывать провалы при высокой конкурентной нагрузке между инстансами, но остаётся fail-safe. Полную аннотированную версию см. в examples/redis-circuit-breaker-store.ts.