rest-pipeline-js 1.3.5: Retry-After, авторизация через 401 и безопасные метрики

Есть вещи, которые в HTTP-клиенте кажутся очевидными, но почему-то редко реализованы нормально. Например, уважать заголовок Retry-After. Или автоматически обновлять токен при 401 без танцев с бубном. Или не светить Authorization в логах.
В версии 1.3.5 мы закрыли все три задачи. Клиент стал умнее, безопаснее и приятнее в эксплуатации.
Важно: Это обновление не ломает обратную совместимость. Все новые фичи опциональны.
Retry-After: теперь клиент умеет ждать правильно
Раньше повторные запросы работали «вслепую»: сервер мог ответить 429 Too Many Requests с заголовком Retry-After: 120, но клиент всё равно дёргал по экспоненте. Это создавало лишнюю нагрузку и игнорировало прямые указания API.
Теперь клиент парсит Retry-After в двух форматах:
typescript
// Числовой формат (секунды)
Retry-After: 120 // → задержка 120_000 мс
// HTTP-дата
Retry-After: Fri, 31 Dec 2026 23:59:59 GMT // → задержка до указанного момента
Как это работает внутри
В RequestExecutor добавлен парсер с защитой от дурака:
typescript
function parseRetryAfter(value: string, maxMs: number): number | null {
// Числовой формат
const asNumber = Number(value);
if (!isNaN(asNumber) && value.trim() !== '') {
return Math.min(Math.max(asNumber * 1000, 0), maxMs);
}
// Формат HTTP-даты
const asDate = new Date(value);
if (!isNaN(asDate.getTime())) {
const waitMs = asDate.getTime() - Date.now();
return Math.min(Math.max(waitMs, 0), maxMs);
}
return null; // Не распарсилось — фоллбэк на backoff
}
Приоритет логики повторных запросов:
- Если есть
Retry-Afterи он распарсился → используем его (но не большеmaxRetryAfterMs). - Если
Retry-Afterне пришёл или невалидный → экспоненциальный backoff с jitter. - Если
baseDelay === 0→ без задержки.
Настройка потолка для Retry-After
Сервер иногда просит подождать час. Это может быть неоправданно долго для клиентского приложения. Теперь есть защита:
typescript
const client = createRestClient({
baseURL: "https://api.example.com",
retry: {
attempts: 3,
delayMs: 1000,
backoffMultiplier: 2,
retriableStatus: [429, 500, 502, 503, 504],
maxRetryAfterMs: 30_000, // ← даже если сервер сказал 120 секунд, ждём максимум 30
},
});
По умолчанию maxRetryAfterMs = 60_000 (одна минута).
Автоматическое обновление токена при 401
Классическая проблема: получили 401 Unauthorized — нужно обновить токен и повторить запрос. Раньше это приходилось делать руками в каждом компоненте или писать перехватчик.
Теперь это встроено в клиента:
typescript
const client = createRestClient({
baseURL: "https://api.example.com",
auth: {
getToken: async () => {
return localStorage.getItem("access_token") ?? "";
},
onUnauthorized: async () => {
// Вызывается только при 401
const newToken = await refreshToken();
localStorage.setItem("access_token", newToken);
},
},
});
// Всё остальное происходит автоматически
const data = await client.get("/user/profile");
Как это работает под капотом
typescript
async function request<T>(command: string, req?: RestRequestConfig, _retried = false) {
// Получаем токен перед каждым запросом
const token = await config.auth.getToken();
const headers = { Authorization: `Bearer ${token}`, ...req?.headers };
try {
const response = await httpClient.request({ url: command, headers, ...req });
return response.data;
} catch (error) {
// Если 401 и это не повторный вызов — вызываем onUnauthorized и рекурсивно повторяем
if (config.auth && !_retried && error.response?.status === 401) {
await config.auth.onUnauthorized();
return request<T>(command, req, true); // ← флаг _retried предотвращает бесконечный цикл
}
throw error;
}
}
Важные детали реализации:
getToken()вызывается перед каждым запросом — токен всегда свежий.onUnauthorized()вызывается только при 401.- Флаг
_retriedзащищает от бесконечной петли — если после обновления токена снова пришёл 401, клиент не будет повторять вечно. - Повтор происходит ровно один раз. Второй 401 — это уже ошибка приложения.
Что если несколько параллельных запросов получили 401?
В текущей реализации каждый запрос вызовет onUnauthorized() отдельно. Для большинства сценариев это не критично (токен просто обновится несколько раз). Но если вы хотите избежать лишних вызовов — можно реализовать кеширование промиса обновления внутри onUnauthorized:
typescript
let refreshPromise: Promise<string> | null = null;
const auth = {
getToken: () => localStorage.getItem("access_token") ?? "",
onUnauthorized: async () => {
if (!refreshPromise) {
refreshPromise = refreshToken().then(token => {
localStorage.setItem("access_token", token);
refreshPromise = null;
return token;
});
}
await refreshPromise;
},
};
Этот паттерн можно использовать поверх библиотеки — он не зашит внутрь, чтобы не усложнять код для 90% сценариев.
Санитизация заголовков в логах
Проблема: в production вы, скорее всего, логируете метрики запросов: URL, статус, длительность, заголовки. И случайно можете отправить в Sentry или DataDog Authorization: Bearer секретный_токен. GDPR и здравый смысл такого не одобряют.
Решение: включаем sanitizeHeaders: true, и чувствительные заголовки маскируются.
typescript
const client = createRestClient({
baseURL: "https://api.example.com",
sanitizeHeaders: true, // ← маскируем чувствительные заголовки в метриках
metrics: {
onRequestStart: (info) => {
console.log(info.requestHeaders);
// { authorization: 'REDACTED', 'content-type': 'application/json' }
},
},
});
Какие заголовки маскируются по умолчанию?
typescript
export const DEFAULT_SENSITIVE_HEADERS = [
'authorization', // Bearer, Basic
'x-api-key', // API-ключи
'x-auth-token', // Альтернативные токены
'cookie', // Сессионные куки
'set-cookie', // Ответы сервера
'proxy-authorization',
];
Добавляем свои заголовки
typescript
const client = createRestClient({
baseURL: "https://api.example.com",
sanitizeHeaders: true,
sensitiveHeaders: ['x-custom-secret', 'x-internal-token'],
});
Сравнение без учёта регистра — не важно, напишете вы X-Custom-Secret или x-custom-secret.
Как это устроено внутри
typescript
export function sanitizeHeadersMap(
headers: Record<string, string> | undefined,
extraSensitive: string[] = [],
): Record<string, string> | undefined {
if (!headers) return headers;
const blocked = new Set([
...DEFAULT_SENSITIVE_HEADERS.map(h => h.toLowerCase()),
...extraSensitive.map(h => h.toLowerCase()),
]);
return Object.fromEntries(
Object.entries(headers).map(([k, v]) =>
blocked.has(k.toLowerCase()) ? [k, 'REDACTED'] : [k, v]
)
);
}
Важно: санитизация применяется только к данным, которые передаются в metrics. Оригинальный запрос идёт с реальными заголовками. Ваши логи в безопасности, API работает как обычно.
Что ещё исправлено и улучшено
Баги, которые тихо жили в коде
| Проблема | Было | Стало |
|---|---|---|
condition не проверялся |
Шаги с condition: false всё равно выполнялись |
Шаг пропускается со статусом "skipped" |
request() вызывался дважды |
При каждом шаге — два вызова пользовательской функции | Ровно один вызов |
rerunStep() игнорировал хуки |
before, after, condition не срабатывали |
Полный цикл как при первом запуске |
| Таймаут не отменял HTTP-запрос | Promise.race только отклонял промис, запрос висел в фоне |
AbortController реально отменяет запрос |
Русские сообщения в toApiError |
"Запрос был отменен", "Произошла неизвестная ошибка" |
Английские строки |
Новые тесты
В tests/rest-client.test.ts добавлено +214 строк тестов, покрывающих:
- Санитизацию заголовков (
sanitizeHeadersMap) DEFAULT_SENSITIVE_HEADERSи регистронезависимость- Auth Provider:
getToken()вызывается перед каждым запросом onUnauthorized()при 401 и защиту от бесконечного цикла- Параллельные запросы с 401 (не должны уйти в рекурсию)
Миграция с 1.3.0
Ничего ломать не нужно. Все новые фичи опциональны:
retry.maxRetryAfterMs— добавили новое поле, старые конфиги работают.auth— если не передавать, клиент ведёт себя как раньше.sanitizeHeaders— по умолчаниюfalse, включайте когда нужно.