~/wiki / integratsii-i-api / api-queue-retry-circuit-breaker-for-beginners

Что делать, если API иногда не отвечает: очереди, повторные попытки и circuit breaker простыми словами

Основной чат

Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.

$ cd раздел/ $ join vibe dev
Что делать, если API иногда не отвечает: очереди, повторные попытки и circuit breaker простыми словами - обложка

Вы написали код, который обращается к внешнему сервису — к API погоды, к платёжной системе, к сервису отправки email. Всё работает на вашем компьютере. Вы запускаете в продакшене — и раз в несколько часов что-то идёт не так. API отвечает с задержкой, возвращает ошибку, или вообще не отвечает. Пользователь видит белый экран или ошибку 500.

Это не баг в вашем коде. Это нормальная жизнь: любой внешний сервис иногда недоступен. Вопрос в том, как ваше приложение ведёт себя в этот момент.

В этой статье — три инструмента, которые решают проблему: повторные попытки, очереди и circuit breaker. Объясняем каждый простыми словами, с аналогиями, и показываем как это выглядит в коде.


Почему API иногда не отвечает

Прежде чем лечить — стоит понять причины. API может не ответить по-разному, и это важно, потому что разные причины требуют разных решений.

Временная перегрузка. Сервер справляется с нагрузкой, но прямо сейчас получил слишком много запросов. Через секунду-две — снова работает. Решение: подождать и попробовать ещё раз.

Превышение лимита запросов (rate limit). Вы отправили слишком много запросов за короткое время. API говорит «стоп, подожди минуту». Решение: соблюдать очередь.

Сеть. Пакет потерялся по пути. Ваш сервер и сервер API оба живые, но конкретный запрос не дошёл. Решение: повторить.

API реально упал. Что-то сломалось на стороне провайдера, и он будет лежать час или два. Решение: прекратить попытки на время и продолжить позже.

Медленный ответ. API живой, но отвечает за 30 секунд вместо обычных 2. Решение: не ждать вечно, поставить таймаут.

Каждый случай требует своей стратегии. Сейчас разберём их по порядку.


Повторные попытки (retry): попробуй ещё раз, но с умом

Что это и зачем

Самая простая идея: если запрос не получился — попробуй ещё раз. Как когда звонишь кому-то и слышишь «абонент недоступен» — кладёшь трубку и перезваниваешь через минуту.

Наивная реализация выглядит так:

javascript
// Плохой 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). Слово звучит сложно, но идея простая: каждая следующая пауза вдвое длиннее предыдущей.

plaintext
Попытка 1: запрос → ошибка → пауза 1 секунда
Попытка 2: запрос → ошибка → пауза 2 секунды
Попытка 3: запрос → ошибка → пауза 4 секунды
Попытка 4: запрос → успех ✓

Зачем нарастающая, а не фиксированная пауза? Если сервер восстанавливается, он сначала едва справляется с нагрузкой. Фиксированные паузы создают одинаковую волну запросов снова и снова. Нарастающие паузы дают серверу всё больше времени на восстановление.

Jitter: добавляем случайность

Представьте, что у вас 100 пользователей одновременно получили ошибку. Все 100 ждут ровно 1 секунду и одновременно повторяют запрос. Сервер снова падает под нагрузкой. Все ждут 2 секунды — и снова синхронный удар.

Jitter (от английского «дрожание») — это небольшой случайный сдвиг в задержке. Вместо ровно 1 секунды — от 0.8 до 1.2 секунды. Звучит как мелочь, но 100 пользователей теперь распределяются во времени, а не бьют синхронно.

javascript
// 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-сервис. Вы пишете:

javascript
app.post('/register', async (req, res) => {
    await createUser(req.body);
    await sendWelcomeEmail(req.body.email); // вот здесь
    res.json({ ok: true });
});

Что происходит, если email-сервис в момент регистрации недоступен? Пользователь получает ошибку. Аккаунт создан, но он об этом не знает — не получил письмо с подтверждением. Можно добавить retry — но тогда пользователь ждёт несколько секунд пока вы делаете повторные попытки. Плохо.

Что такое очередь задач

Очередь задач — это список дел, которые нужно сделать. Вы добавляете задачу в список («отправить письмо такому-то») и сразу отвечаете пользователю «всё ок». Письмо будет отправлено чуть позже, в фоне, отдельным процессом.

Хорошая аналогия — касса в супермаркете. Когда вы пробиваете товар, кассир не звонит немедленно поставщику за новой партией. Она просто фиксирует продажу, и склад потом сам разбирается с пополнением запасов. Касса не ждёт поставщика — она работает дальше.

plaintext
Пользователь регистрируется
       ↓
Ваш сервер: создать аккаунт + добавить задачу в очередь
       ↓
Ответ пользователю: «всё готово» (быстро!)
       ↓ (в фоне, отдельный процесс)
Воркер берёт задачу из очереди
       ↓
Воркер отправляет письмо
       ↓ (если не получилось)
Воркер повторяет попытку через N секунд

Что даёт очередь

Надёжность. Задача не потеряется. Если воркер упал — при перезапуске он возьмёт незавершённые задачи и продолжит.

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

Контроль нагрузки. Можно ограничить скорость обработки. Если email-сервис принимает 10 писем в секунду — воркер будет отправлять именно с такой скоростью, не больше.

Retry из коробки. Хорошие библиотеки очередей умеют автоматически повторять задачу при ошибке с экспоненциальной задержкой.

Пример с BullMQ (Node.js)

BullMQ — популярная библиотека очередей для Node.js, использует Redis как хранилище задач.

bash
npm install bullmq ioredis
javascript
// 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 };
javascript
// 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.

plaintext
     Слишком много ошибок
CLOSED ─────────────────────→ OPEN
  ↑                              │
  │    Пробный запрос            │ Истекло время ожидания
  │    прошёл успешно            ↓
  └──────────────────────── HALF-OPEN

Аналогия для понимания

Представьте, что вы звоните в службу поддержки. Три раза подряд слышите «все операторы заняты» и трубку кладут. На четвёртый раз вы понимаете: сейчас там явно что-то случилось, не стоит продолжать звонить каждые 30 секунд. Вы решаете перезвонить через час.

Через час звоните — дозвонились. Circuit breaker работает по той же логике, только автоматически.

Пример circuit breaker (Node.js)

javascript
// 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 };
javascript
// Использование
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 секунд, убивая ресурсы сервера. С ним — сразу быстрый ответ «недоступно», нагрузка не накапливается.

Очередь — для задач, которые не нужны прямо сейчас. Отправка писем, уведомлений, генерация отчётов — всё, что можно сделать чуть позже. Задача не потеряется, попробует снова когда сервис восстановится.

plaintext
Запрос к API
      │
      ▼
[Circuit Breaker]
  OPEN? ──→ сразу «недоступно» → добавить в очередь
  CLOSED/HALF-OPEN ↓
      │
      ▼
[Retry с задержкой]
  Успех ──→ вернуть результат
  Ошибка после всех попыток ──→ сообщить circuit breaker
                                └→ если не срочно — добавить в очередь

Что показать пользователю когда ничего не помогло

Это важная часть, которую часто забывают. Если все попытки исчерпаны — пользователь должен получить понятный ответ, а не «Internal Server Error».

Плохо:

plaintext
500 Internal Server Error

Хорошо:

plaintext
Сервис оплаты временно недоступен. Ваш заказ сохранён —
мы обработаем оплату автоматически в течение 15 минут
и пришлём подтверждение на email.

Разница огромная. Первый ответ пугает и непонятен. Второй — честный, объясняет что произошло и что будет дальше.

Если задача добавлена в очередь — скажите об этом:

javascript
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 — он реализует всё из этой статьи в нескольких строках:

javascript
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'));

Чеклист

plaintext
Когда добавлять 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: как часто он бывает нестабилен и как долго обычно восстанавливается.

$ cd ../ ← назад к Интеграции и API