У Prowid Photos есть сервис уведомлений: он считает цифру на «колокольчике» в шапке сайта. В пятницу вечером он начинает отвечать не за 20 мс, а за 5 секунд — и лента, которой нужна была всего одна цифра, перестаёт открываться у всех, хотя фотографии и база в порядке. В этом уроке разберём, как мелкая поломка становится аварией и какие приёмы её останавливают: таймауты, повторы, идемпотентность, circuit breaker, rate limiting, деградация и health checks.

Всё ломается: каскадный сбой

Сеть теряет пакеты, деплой перезапускает сервер, кончается место на диске. В системе из нескольких частей что-то сломано почти всегда (доступности звеньев перемножаются, помните первый урок модуля). Надёжность — это не «ничего не ломается», а «когда часть сломалась, остальное работает».

Что делает зависший сервис? Число запросов, одновременно «висящих» в системе, равно запросов в секунду × время ответа. При 350 RPS и ответе за 50 мс это 350 × 0,05 ≈ 18 запросов. Если каждый ждёт уведомления 5 секунд, их 350 × 5 = 1750. Каждый держит сокет, память, возможно соединение с базой. Экземпляры упираются в лимиты, балансировщик видит провал health check, а пользователи жмут «обновить» и добавляют нагрузку. Это каскадный сбой (cascading failure): отказ второстепенной части тянет вниз главные. Наша цель — удержать поломку там, где она случилась.

Таймауты: у каждого вызова есть предел

Вы звоните другу, он не берёт трубку. Через полминуты вы повесите её, а не будете слушать гудки до вечера. Код должен вести себя так же. fetch в Node.js по умолчанию может ждать минутами, а у многих драйверов лимита нет вовсе. Современный способ — AbortSignal.timeout(): по истечении времени fetch завершается ошибкой TimeoutError.

js
export async function fetchJson(url, { timeoutMs = 2000, ...init } = {}) {
  const res = await fetch(url, {
    ...init,
    signal: AbortSignal.timeout(timeoutMs), // abort after timeoutMs
  });
  if (!res.ok) {
    const err = new Error(`HTTP ${res.status}`);
    throw Object.assign(err, { status: res.status });
  }
  return res.json(); // the same signal also covers reading the body
}

try {
  await fetchJson('http://notifications:4001/unread/42', { timeoutMs: 300 });
} catch (err) {
  if (err.name === 'TimeoutError') console.warn('notifications is too slow');
  else throw err;
}

Как выбрать значение? Откройте перцентили из первого урока: если p99 уведомлений — 200 мс, ставьте 300–500 мс, а не 30 секунд «на всякий случай». Для цепочки вызовов нужен бюджет времени: лента отвечает за 300 мс на p95, поэтому три последовательных вызова по 2 секунды невозможны.

⚠️ Таймаут не отменяет работу на сервере: уведомления могли выполнить запрос, просто ответ не успел. Повтор после таймаута может сделать дело дважды — об этом ниже.

Повторы: ещё раз, но с умом

Многие сбои короткие: сеть моргнула, сервер перезапустился при деплое. Повтор спасает такие случаи, но повторять можно не всё:

СитуацияПовторять?
сетевая ошибка или таймаут у GETда
502, 503, 504; 429 — после паузы из Retry-Afterда
400, 401, 403, 404, 422нет, ответ не изменится
POST без ключанет, опасно