Что делать, если API иногда не отвечает: очереди, повторные попытки и circuit breaker простыми словами
Основной чат
Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.
Вы написали код, который обращается к внешнему сервису — к API погоды, к платёжной системе, к сервису отправки email. Всё работает на вашем компьютере. Вы запускаете в продакшене — и раз в несколько часов что-то идёт не так. API отвечает с задержкой, возвращает ошибку, или вообще не отвечает. Пользователь видит белый экран или ошибку 500.
Это не баг в вашем коде. Это нормальная жизнь: любой внешний сервис иногда недоступен. Вопрос в том, как ваше приложение ведёт себя в этот момент.
В этой статье — три инструмента, которые решают проблему: повторные попытки, очереди и circuit breaker. Объясняем каждый простыми словами, с аналогиями, и показываем как это выглядит в коде.
Почему API иногда не отвечает
Прежде чем лечить — стоит понять причины. API может не ответить по-разному, и это важно, потому что разные причины требуют разных решений.
Временная перегрузка. Сервер справляется с нагрузкой, но прямо сейчас получил слишком много запросов. Через секунду-две — снова работает. Решение: подождать и попробовать ещё раз.
Превышение лимита запросов (rate limit). Вы отправили слишком много запросов за короткое время. API говорит «стоп, подожди минуту». Решение: соблюдать очередь.
Сеть. Пакет потерялся по пути. Ваш сервер и сервер API оба живые, но конкретный запрос не дошёл. Решение: повторить.
API реально упал. Что-то сломалось на стороне провайдера, и он будет лежать час или два. Решение: прекратить попытки на время и продолжить позже.
Медленный ответ. API живой, но отвечает за 30 секунд вместо обычных 2. Решение: не ждать вечно, поставить таймаут.
Каждый случай требует своей стратегии. Сейчас разберём их по порядку.
Повторные попытки (retry): попробуй ещё раз, но с умом
Что это и зачем
Самая простая идея: если запрос не получился — попробуй ещё раз. Как когда звонишь кому-то и слышишь «абонент недоступен» — кладёшь трубку и перезваниваешь через минуту.
Наивная реализация выглядит так:
// Плохой retry: три попытки подряд, без паузы
async function fetchData() {
for (let i = 0; i < 3; i++) {
try {
return await api.get('/data');
} catch (error) {
if (i === 2) throw error; // последняя попытка — пробрасываем ошибку
}
}
}
Это лучше, чем ничего. Но у такого подхода есть проблема: три попытки происходят мгновенно одна за другой. Если сервер перегружен — вы не даёте ему передышку, а наоборот добавляете три запроса вместо одного.
Пауза между попытками: экспоненциальная задержка
Правильный retry — это retry с нарастающей паузой. Не получилось — подождал секунду. Снова не получилось — подождал две секунды. Ещё раз — четыре секунды. И так далее.
Это называется экспоненциальная задержка (exponential backoff). Слово звучит сложно, но идея простая: каждая следующая пауза вдвое длиннее предыдущей.
Попытка 1: запрос → ошибка → пауза 1 секунда
Попытка 2: запрос → ошибка → пауза 2 секунды
Попытка 3: запрос → ошибка → пауза 4 секунды
Попытка 4: запрос → успех ✓
Зачем нарастающая, а не фиксированная пауза? Если сервер восстанавливается, он сначала едва справляется с нагрузкой. Фиксированные паузы создают одинаковую волну запросов снова и снова. Нарастающие паузы дают серверу всё больше времени на восстановление.
Jitter: добавляем случайность
Представьте, что у вас 100 пользователей одновременно получили ошибку. Все 100 ждут ровно 1 секунду и одновременно повторяют запрос. Сервер снова падает под нагрузкой. Все ждут 2 секунды — и снова синхронный удар.
Jitter (от английского «дрожание») — это небольшой случайный сдвиг в задержке. Вместо ровно 1 секунды — от 0.8 до 1.2 секунды. Звучит как мелочь, но 100 пользователей теперь распределяются во времени, а не бьют синхронно.
// Retry с экспоненциальной задержкой и jitter
async function withRetry(fn, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
// Если это последняя попытка — пробрасываем ошибку
if (attempt === maxAttempts) throw error;
// Базовая задержка: 1s, 2s, 4s...
const baseDelay = 1000 * Math.pow(2, attempt - 1);
// Jitter: ±25% от базовой задержки
const jitter = baseDelay * 0.25 * (Math.random() * 2 - 1);
const delay = Math.round(baseDelay + jitter);
console.log(`Попытка ${attempt} не удалась. Повтор через ${delay}мс...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Использование — ваш код не меняется, просто оборачиваете вызов
const data = await withRetry(() => api.get('/weather'));
Важно: не всё нужно ретраить
Повторная попытка имеет смысл только для временных проблем. Если API вернул ошибку «неверный API-ключ» — сколько бы раз вы ни повторяли, ответ не изменится. Ретраить имеет смысл только:
- сетевые ошибки (соединение разорвалось)
- статус 429 (слишком много запросов — подождать)
- статус 500, 502, 503, 504 (временные проблемы сервера)
Не нужно ретраить:
- 400 (неправильный запрос — исправьте запрос)
- 401 (неверный ключ — проверьте ключ)
- 403 (нет прав — обратитесь к провайдеру)
- 404 (ресурс не существует — не существует)
Очереди: делай работу по очереди, не теряй задачи
Проблема без очереди
Представьте, что ваш сайт при регистрации пользователя должен отправить приветственное письмо через email-сервис. Вы пишете:
app.post('/register', async (req, res) => {
await createUser(req.body);
await sendWelcomeEmail(req.body.email); // вот здесь
res.json({ ok: true });
});
Что происходит, если email-сервис в момент регистрации недоступен? Пользователь получает ошибку. Аккаунт создан, но он об этом не знает — не получил письмо с подтверждением. Можно добавить retry — но тогда пользователь ждёт несколько секунд пока вы делаете повторные попытки. Плохо.
Что такое очередь задач
Очередь задач — это список дел, которые нужно сделать. Вы добавляете задачу в список («отправить письмо такому-то») и сразу отвечаете пользователю «всё ок». Письмо будет отправлено чуть позже, в фоне, отдельным процессом.
Хорошая аналогия — касса в супермаркете. Когда вы пробиваете товар, кассир не звонит немедленно поставщику за новой партией. Она просто фиксирует продажу, и склад потом сам разбирается с пополнением запасов. Касса не ждёт поставщика — она работает дальше.
Пользователь регистрируется
↓
Ваш сервер: создать аккаунт + добавить задачу в очередь
↓
Ответ пользователю: «всё готово» (быстро!)
↓ (в фоне, отдельный процесс)
Воркер берёт задачу из очереди
↓
Воркер отправляет письмо
↓ (если не получилось)
Воркер повторяет попытку через N секунд
Что даёт очередь
Надёжность. Задача не потеряется. Если воркер упал — при перезапуске он возьмёт незавершённые задачи и продолжит.
Скорость ответа. Пользователь не ждёт, пока письмо реально отправится. Он получает ответ немедленно.
Контроль нагрузки. Можно ограничить скорость обработки. Если email-сервис принимает 10 писем в секунду — воркер будет отправлять именно с такой скоростью, не больше.
Retry из коробки. Хорошие библиотеки очередей умеют автоматически повторять задачу при ошибке с экспоненциальной задержкой.
Пример с BullMQ (Node.js)
BullMQ — популярная библиотека очередей для Node.js, использует Redis как хранилище задач.
npm install bullmq ioredis
// queue.js — настройка очереди
const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');
const connection = new Redis(process.env.REDIS_URL);
// Создаём очередь
const emailQueue = new Queue('emails', { connection });
// Добавляем задачу — вызывается при регистрации
async function scheduleWelcomeEmail(userEmail, userName) {
await emailQueue.add(
'welcome', // название задачи
{ email: userEmail, name: userName }, // данные
{
attempts: 5, // максимум 5 попыток
backoff: {
type: 'exponential', // задержка нарастает
delay: 2000, // начиная с 2 секунд
},
}
);
console.log(`Задача на письмо для ${userEmail} добавлена в очередь`);
}
// Воркер — отдельный процесс, который обрабатывает задачи
const worker = new Worker('emails', async (job) => {
console.log(`Отправляем письмо: ${job.data.email}`);
await sendEmail(job.data.email, job.data.name);
console.log(`Письмо отправлено: ${job.data.email}`);
}, { connection });
worker.on('failed', (job, error) => {
console.error(`Задача ${job.id} не удалась: ${error.message}`);
});
module.exports = { scheduleWelcomeEmail };
// server.js — эндпоинт регистрации
const { scheduleWelcomeEmail } = require('./queue');
app.post('/register', async (req, res) => {
// Создаём пользователя
const user = await createUser(req.body);
// Добавляем в очередь — не ждём отправки!
await scheduleWelcomeEmail(user.email, user.name);
// Отвечаем пользователю немедленно
res.json({ ok: true, userId: user.id });
});
Обратите внимание: scheduleWelcomeEmail — это быстрая операция (просто запись в Redis). Пользователь получает ответ за миллисекунды. Письмо уйдёт чуть позже, в фоне.
Когда нужна очередь, а когда нет
Очередь нужна когда:
- задача может занять больше секунды
- задача может провалиться и нужно повторить позже
- важно не потерять задачу при перезапуске сервера
- нужно контролировать скорость выполнения
Очередь не нужна когда:
- результат нужен пользователю прямо сейчас (поиск, авторизация)
- операция простая и всегда быстрая
- потеря задачи при сбое допустима
Circuit Breaker: автоматический предохранитель
Проблема без circuit breaker
Представьте, что API платёжной системы лёг на два часа. У вас настроен retry: три попытки с паузами. Каждый запрос пользователя занимает теперь ~15 секунд (три попытки с нарастающими паузами) — и всё равно заканчивается ошибкой.
При нагрузке 100 запросов в минуту у вас одновременно висят сотни «застрявших» запросов. Они занимают воркеры, память, соединения с базой данных. Сервер деградирует или падает — и всё это из-за внешнего API, с которым ваш код ничего общего не имеет.
Что такое circuit breaker
Circuit breaker дословно — «автоматический выключатель». Это тот же предохранитель, что стоит в электрощитке. Пока ток нормальный — всё работает. Когда пошло короткое замыкание — предохранитель срабатывает и отключает цепь, не давая сгореть всей проводке.
В программировании circuit breaker следит за ошибками при обращении к API. Если ошибок стало слишком много — он «выбивает» и временно прекращает отправлять запросы к проблемному сервису. Через некоторое время — пробует снова, и если всё хорошо — возвращается к нормальной работе.
Три состояния
Circuit breaker работает как переключатель с тремя положениями:
CLOSED (замкнут) — нормальная работа. Запросы проходят свободно. Circuit breaker считает ошибки. Пока их мало — ничего не происходит.
OPEN (разомкнут) — защита активирована. Ошибок стало слишком много. Circuit breaker больше не пропускает запросы к API — сразу возвращает ошибку, не тратя время на попытку. Пользователь получает быстрый ответ «сервис временно недоступен» вместо ожидания 15 секунд.
HALF-OPEN (полуоткрыт) — проверка. Прошло достаточно времени. Circuit breaker пускает один пробный запрос. Если получилось — переходит обратно в CLOSED. Если нет — снова OPEN.
Слишком много ошибок
CLOSED ─────────────────────→ OPEN
↑ │
│ Пробный запрос │ Истекло время ожидания
│ прошёл успешно ↓
└──────────────────────── HALF-OPEN
Аналогия для понимания
Представьте, что вы звоните в службу поддержки. Три раза подряд слышите «все операторы заняты» и трубку кладут. На четвёртый раз вы понимаете: сейчас там явно что-то случилось, не стоит продолжать звонить каждые 30 секунд. Вы решаете перезвонить через час.
Через час звоните — дозвонились. Circuit breaker работает по той же логике, только автоматически.
Пример circuit breaker (Node.js)
// lib/circuit-breaker.js
class CircuitBreaker {
constructor(options = {}) {
// После скольких ошибок подряд «выбить» предохранитель
this.failureThreshold = options.failureThreshold || 5;
// Сколько миллисекунд ждать перед пробным запросом
this.recoveryTimeout = options.recoveryTimeout || 60_000; // 1 минута
// Внутреннее состояние
this.failures = 0; // счётчик ошибок подряд
this.state = 'CLOSED'; // текущее состояние
this.nextAttemptTime = null; // когда можно попробовать снова
}
async call(fn) {
// Если OPEN — проверяем, не пора ли попробовать снова
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttemptTime) {
// Ещё рано — возвращаем ошибку сразу, без запроса
const waitSec = Math.round((this.nextAttemptTime - Date.now()) / 1000);
throw new Error(`Сервис временно недоступен. Повтор через ${waitSec}с`);
}
// Время вышло — переходим в HALF-OPEN для пробного запроса
this.state = 'HALF-OPEN';
console.log('Circuit Breaker: пробный запрос...');
}
try {
const result = await fn();
this._onSuccess();
return result;
} catch (error) {
this._onFailure();
throw error;
}
}
_onSuccess() {
// Запрос прошёл — сбрасываем счётчик, возвращаемся в норму
if (this.state === 'HALF-OPEN') {
console.log('Circuit Breaker: сервис восстановлен, переходим в CLOSED');
}
this.failures = 0;
this.state = 'CLOSED';
}
_onFailure() {
this.failures++;
if (this.state === 'HALF-OPEN') {
// Пробный запрос не удался — снова блокируем
console.warn('Circuit Breaker: пробный запрос не удался, снова OPEN');
this.state = 'OPEN';
this.nextAttemptTime = Date.now() + this.recoveryTimeout;
} else if (this.failures >= this.failureThreshold) {
// Превысили порог ошибок — выбиваем предохранитель
console.error(`Circuit Breaker: ${this.failures} ошибок подряд — переходим в OPEN`);
this.state = 'OPEN';
this.nextAttemptTime = Date.now() + this.recoveryTimeout;
}
}
}
module.exports = { CircuitBreaker };
// Использование
const { CircuitBreaker } = require('./lib/circuit-breaker');
// Создаём один breaker на весь сервис (не на каждый запрос!)
const paymentBreaker = new CircuitBreaker({
failureThreshold: 5, // 5 ошибок подряд — выбить
recoveryTimeout: 60_000 // через 1 минуту — пробный запрос
});
async function processPayment(orderData) {
try {
return await paymentBreaker.call(() =>
paymentApi.post('/charge', orderData)
);
} catch (error) {
if (error.message.includes('временно недоступен')) {
// Circuit breaker сработал — API явно лежит
// Можно добавить задачу в очередь на потом
await paymentQueue.add('retry_payment', orderData, {
delay: 5 * 60_000 // попробовать через 5 минут
});
return { status: 'queued', message: 'Оплата будет обработана позже' };
}
throw error;
}
}
Как всё это работает вместе
Три инструмента не конкурируют — они дополняют друг друга и закрывают разные сценарии.
Retry — для коротких временных сбоев. API чихнул и через 2 секунды ожил. Retry справится сам, пользователь даже не заметит.
Circuit Breaker — для затяжных сбоев. API лежит час. Без circuit breaker каждый запрос будет ждать таймаутов и retry по 15 секунд, убивая ресурсы сервера. С ним — сразу быстрый ответ «недоступно», нагрузка не накапливается.
Очередь — для задач, которые не нужны прямо сейчас. Отправка писем, уведомлений, генерация отчётов — всё, что можно сделать чуть позже. Задача не потеряется, попробует снова когда сервис восстановится.
Запрос к API
│
▼
[Circuit Breaker]
OPEN? ──→ сразу «недоступно» → добавить в очередь
CLOSED/HALF-OPEN ↓
│
▼
[Retry с задержкой]
Успех ──→ вернуть результат
Ошибка после всех попыток ──→ сообщить circuit breaker
└→ если не срочно — добавить в очередь
Что показать пользователю когда ничего не помогло
Это важная часть, которую часто забывают. Если все попытки исчерпаны — пользователь должен получить понятный ответ, а не «Internal Server Error».
Плохо:
500 Internal Server Error
Хорошо:
Сервис оплаты временно недоступен. Ваш заказ сохранён —
мы обработаем оплату автоматически в течение 15 минут
и пришлём подтверждение на email.
Разница огромная. Первый ответ пугает и непонятен. Второй — честный, объясняет что произошло и что будет дальше.
Если задача добавлена в очередь — скажите об этом:
app.post('/send-report', async (req, res) => {
try {
// Пробуем сделать сразу
const result = await reportService.generate(req.body);
return res.json({ status: 'done', url: result.url });
} catch (error) {
// Не получилось — добавляем в очередь
const jobId = await reportQueue.add('generate', req.body);
return res.json({
status: 'queued',
message: 'Отчёт формируется. Мы пришлём его на email когда будет готов.',
jobId
});
}
});
Готовые библиотеки: не нужно писать самому
Circuit breaker и retry — стандартные паттерны, для них есть проверенные библиотеки.
| Язык | Библиотека | Что умеет |
|---|---|---|
| Node.js | cockatiel |
Circuit breaker, retry, timeout — всё вместе |
| Node.js | opossum |
Circuit breaker с метриками |
| Node.js | async-retry |
Простой retry с нарастающей задержкой |
| Python | tenacity |
Retry с гибкими правилами |
| Python | circuitbreaker |
Circuit breaker декоратором |
| Node.js (очереди) | BullMQ |
Очереди на Redis с retry из коробки |
| Python (очереди) | Celery |
Очереди задач с поддержкой Redis и RabbitMQ |
Пример с cockatiel — он реализует всё из этой статьи в нескольких строках:
const { Policy, ConsecutiveBreaker, ExponentialBackoff } = require('cockatiel');
// Создаём политику: circuit breaker + retry
const policy = Policy
.wrap(
// Circuit breaker: выбить после 5 ошибок подряд, восстановление через 30 сек
Policy.handleAll().circuitBreaker(30_000, new ConsecutiveBreaker(5)),
// Retry: 3 попытки с экспоненциальной задержкой
Policy.handleAll().retry().attempts(3).exponential()
);
// Использование — просто оборачиваете любой вызов
const data = await policy.execute(() => api.get('/data'));
Чеклист
Когда добавлять retry:
☐ Запрос может временно не пройти по сети
☐ API иногда возвращает 429 или 500
☐ Добавлена нарастающая задержка (не фиксированная)
☐ Добавлен jitter (случайный сдвиг)
☐ Ретраятся только временные ошибки (не 400, 401, 404)
Когда добавлять очередь:
☐ Результат не нужен пользователю прямо сейчас
☐ Задача может занять больше 2–3 секунд
☐ Нельзя потерять задачу при перезапуске сервера
☐ Нужно контролировать скорость выполнения
Когда добавлять circuit breaker:
☐ API критичен и может надолго лечь
☐ Много параллельных запросов к одному сервису
☐ Хочется быстро отвечать «недоступно» вместо ожидания таймаутов
Что показывать пользователю:
☐ Понятное сообщение, а не технический код ошибки
☐ Если задача в очереди — сообщить об этом
☐ Если возможно — дать примерное время ожидания
Итог
Внешние API падают — это не исключение, это норма. Задача разработчика не «написать код который никогда не падает», а «написать код который правильно себя ведёт когда что-то идёт не так».
Три инструмента из этой статьи покрывают большинство сценариев. Retry — для мелких кратковременных сбоев. Очередь — чтобы не терять задачи и не держать пользователя у экрана. Circuit Breaker — чтобы затяжной сбой в одном сервисе не утянул за собой весь ваш сервер.
Начать можно с малого: добавить retry на самые важные внешние вызовы. Потом — очередь для фоновых задач вроде писем и уведомлений. Circuit breaker — когда видите, что один упавший API начинает влиять на работу всего приложения.
FAQ
Нужны ли все три инструмента сразу? Нет. Начните с retry — это даст 80% надёжности за 20% усилий. Очередь добавляйте когда появляются фоновые задачи (письма, уведомления, отчёты). Circuit breaker — когда замечаете, что сбой одного API влияет на работу всего сервиса.
Что использовать для хранения очереди задач? Для большинства проектов достаточно Redis + BullMQ (Node.js) или Redis + Celery (Python). Redis — быстрый, надёжный, и вы, скорее всего, уже используете его для кэша или сессий.
Сколько попыток делать в retry? Обычно 3–5 достаточно. Больше — редко имеет смысл: если API не ответил за 5 попыток, значит проблема не временная. Для критичных операций лучше добавить задачу в очередь, чем делать 10 попыток подряд.
Circuit breaker срабатывает слишком часто — что настроить?
Увеличьте failureThreshold — количество ошибок подряд до срабатывания. Или уменьшите recoveryTimeout — время до пробного запроса. Оптимальные значения зависят от конкретного API: как часто он бывает нестабилен и как долго обычно восстанавливается.