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

02.04.2026
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 Copy
// Числовой формат (секунды)
Retry-After: 120    // → задержка 120_000 мс

// HTTP-дата
Retry-After: Fri, 31 Dec 2026 23:59:59 GMT   // → задержка до указанного момента

Как это работает внутри

В RequestExecutor добавлен парсер с защитой от дурака:

typescript Copy
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
}

Приоритет логики повторных запросов:

  1. Если есть Retry-After и он распарсился → используем его (но не больше maxRetryAfterMs).
  2. Если Retry-After не пришёл или невалидный → экспоненциальный backoff с jitter.
  3. Если baseDelay === 0 → без задержки.

Настройка потолка для Retry-After

Сервер иногда просит подождать час. Это может быть неоправданно долго для клиентского приложения. Теперь есть защита:

typescript Copy
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 Copy
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 Copy
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 Copy
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 Copy
const client = createRestClient({
  baseURL: "https://api.example.com",
  sanitizeHeaders: true,   // ← маскируем чувствительные заголовки в метриках
  metrics: {
    onRequestStart: (info) => {
      console.log(info.requestHeaders);
      // { authorization: 'REDACTED', 'content-type': 'application/json' }
    },
  },
});

Какие заголовки маскируются по умолчанию?

typescript Copy
export const DEFAULT_SENSITIVE_HEADERS = [
  'authorization',    // Bearer, Basic
  'x-api-key',        // API-ключи
  'x-auth-token',     // Альтернативные токены
  'cookie',           // Сессионные куки
  'set-cookie',       // Ответы сервера
  'proxy-authorization',
];

Добавляем свои заголовки

typescript Copy
const client = createRestClient({
  baseURL: "https://api.example.com",
  sanitizeHeaders: true,
  sensitiveHeaders: ['x-custom-secret', 'x-internal-token'],
});

Сравнение без учёта регистра — не важно, напишете вы X-Custom-Secret или x-custom-secret.

Как это устроено внутри

typescript Copy
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, включайте когда нужно.

Читать далее

03.04.2026

rest-pipeline-js 1.3.6: DAG-переходы, вложенные пайплайны, SWR-кэш и перехватчики

Прошлый релиз добавил параллельные шаги, глобальный middleware и паузу. Но оставалось несколько вещей, которые в пайплайне выглядели как белые пятна.

Например, как сделать нелинейный сценарий? Как переиспользовать цепочку шагов внутри другой цепочки? А в HTTP-клиенте — как обновлять данные в фоне, не заставляя пользователя ждать? И почему до сих пор нет нормальных перехватчиков?

Версия 1.3.6 закрывает всё это.

Метки
rest-pipeline-jspipeline-orchestratordag-transitionsstale-while-revalidatehttp-interceptors
31.03.2026

css-magic-gradient 1.2.0 — гармонии, палитры, WCAG по всей длине и canvas-экспорт

Версия 1.2.0 библиотеки css-magic-gradient: расширенные цветовые гармонии, генераторы тинтов и шейдов, переработанная доступность с проверкой по всем точкам градиента, CSS-переменные, экспорт в canvas и 9 новых хуков для Vue и React.

Метки
css-градиентыtypescriptreactvuewcagcolor-harmony
30.03.2026

color-value-tools 1.1.1: от конвертера форматов до полноценного инструментария для работы с цветом

color-value-tools вырос из простого конвертера цветовых форматов в полноценный инструментарий: CSS Color Level 4, перцептивная интерполяция, цветовые гармонии, симуляция дальтонизма, WCAG-доступность, генераторы и CLI — всё в одном пакете без зависимостей.

Метки
colortypescriptnpmwcagcss