Продвинутое использование
Подробные объяснения и полные примеры для опций createRestClient(), которые не умещаются в одну строку описания — кастомные транспорты, устойчивость к нестабильному/перегруженному backend, распределённое состояние между серверными инстансами и observability.
HTTP-адаптер (кастомный fetch / edge-окружения)
Замените встроенный клиент на axios любой HTTP-реализацией через опцию adapter:
const fetchAdapter = {
async request(config) {
const url = `${config.baseURL ?? ''}${config.url ?? ''}`
const res = await fetch(url, {
method: config.method ?? 'GET',
body: config.data ? JSON.stringify(config.data) : undefined,
headers: { 'Content-Type': 'application/json', ...config.headers },
signal: config.signal,
})
const data = await res.json()
return {
data,
status: res.status,
statusText: res.statusText,
headers: Object.fromEntries(res.headers.entries()),
}
},
}
const client = createRestClient({
baseURL: 'https://api.example.com',
adapter: fetchAdapter,
// auth, interceptors, sanitizeHeaders, metrics по-прежнему работают поверх адаптера
auth: { getToken: async () => token },
})type HttpAdapter = {
request<T = unknown>(config: RestRequestConfig & { baseURL?: string }): Promise<ApiResponse<T>>
}Когда задан adapter, createRestClient() никогда не вызывает axios.create() — встроенный экземпляр axios просто не создаётся, так что использование только с адаптером (например, в Cloudflare Workers / Deno) не несёт за это накладных расходов.
Распределённое ограничение частоты запросов (RateLimiterStore)
По умолчанию rateLimit применяется in-memory, в рамках одного экземпляра клиента — нормально для браузерного SPA, но в развёртывании с несколькими инстансами каждый серверный процесс применяет свой собственный лимит, так что N инстансов фактически допускают N-кратный настроенный лимит. Передайте rateLimit.store, чтобы разделить лимит между инстансами (например, через Redis):
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:
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'а.
// Вручную: генерируем ключ один раз на логическую операцию, переиспользуем на всех попытках
const idempotencyKey = crypto.randomUUID()
await client.post('/orders', cart, { idempotencyKey })
// Кастомное имя заголовка
const client = createRestClient({ idempotencyHeaderName: 'X-Idempotency-Key' })RequestExecutor (класс, который на самом деле реализует повторы; собственные client.post()/и т. д. у createRestClient() сами по себе не повторяют запрос) может сгенерировать ключ за вас автоматически через autoIdempotencyKey:
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 на каждой попытке повтора — как только связь восстановится. Настраивается через offlineQueue:
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:
tracing.generateTraceparent добавляет заголовок W3C Trace Context traceparent к каждому запросу (пропускается, если запрос уже явно задаёт его), чтобы любой backend/APM, понимающий trace context, мог коррелировать вызов с остальной распределённой трассировкой:
const client = createRestClient({
baseURL: 'https://api.example.com',
tracing: { generateTraceparent: true },
})Передайте traceId в запросе, чтобы коррелировать несколько вызовов под одной трассой вместо нового случайного значения каждый раз — runId (UUID) пайплайна без дефисов — это ровно те 32 hex-символа, которые нужны формату:
await client.get('/users/1', { traceId: orchestrator.getRunId().replace(/-/g, '') })tracing.provider оборачивает каждый запрос в реальный span в вашей системе трассировки. Его форма (TracingProvider/TracingSpan) намеренно является подмножеством API Span из OpenTelemetry (duck-typing — этот пакет не зависит от @opentelemetry/api), так что настоящий OTel SDK подключается как тонкий адаптер:
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.
Провайдер аутентификации
Настраивается через auth. Автоматически добавляет заголовок Authorization: Bearer <token> перед каждым запросом. При ответе 401 вызывается onUnauthorized (например, для обновления токена), и запрос повторяется один раз — это предотвращает бесконечные циклы.
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, поэтому следующий запрос всегда получает свежий токен перед повтором:
const client = createRestClient({
baseURL: 'https://api.example.com',
auth: {
getToken: async () => requestTokenFromSecureEnclave(), // дорогая операция
onUnauthorized: async () => refreshAccessToken(),
tokenTtlMs: 5 * 60_000, // переиспользовать до 5 минут
},
})Санитизация логов
Маскирует чувствительные заголовки в коллбэках метрик (onRequestStart / onRequestEnd), чтобы они никогда не попадали в логи. Настраивается через sanitizeHeaders/sensitiveHeaders.
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 напрямую:
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" }Circuit breaker
Защищает падающий backend (и ваше собственное приложение) от накопления повторов/таймаутов. Настраивается через circuitBreaker: после failureThreshold последовательных провалов клиент полностью перестаёт обращаться к сети на openMs и немедленно отклоняет запросы с CircuitOpenError (code: "CIRCUIT_OPEN"). После openMs он пропускает один пробный запрос (half-open); успех снова закрывает цепь, провал — открывает заново.
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 между инстансами:
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.