Проблема: Вы запустили запрос к зависимому сервису, тот ответил HTTP 500
, и вся цепочка вызовов рухнула. Система из 20 микросервисов гарантированно даст сбой, если не спроектирована работа с ошибками. Стандартный try/catch
здесь бесполезен — сетевые сбои, таймауты и перегруженные сервисы требуют иного подхода.
Реальность распределенных систем: Ошибки не исключение, а норма. Простые стратегии (например, автоматический повтор) могут усугубить проблемы и спровоцировать каскадный отказ. Рассмотрим тактики, превращающие хрупкие связи в устойчивые.
Паттерн #1: Интеллектуальные повторы с экспоненциальной отсрочкой
Простые линейные повторы под нагрузкой создают эффект «DDoS самому себе». Решение — адаптивная задержка между попытками:
async function fetchWithRetry(url, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await axios.get(url, { timeout: 2000 });
return response.data;
} catch (err) {
if (!isRetryableError(err)) throw err; // Повторяем только сетевые/5хх ошибки
attempt++;
const delay = Math.pow(2, attempt) * 100 + Math.random() * 100; // Экспоненциальная задержка + джиттер
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error(`Max retries (${maxRetries}) exceeded`);
}
// Проверяем тип ошибки
function isRetryableError(err) {
return err.code === 'ECONNABORTED' ||
err.code === 'ECONNRESET' ||
(err.response && err.response.status >= 500);
}
Почему экспоненциально и с джиттером?
- Быстрый первый повтор: возможно, сбой временный
- Увеличение интервалов снижает нагрузку на аварийный сервис
- Случайная добавка (джиттер) предотвращает синхронизацию запросов от разных клиентов (эффект «толпы»)
Паттерн #2: Автоматический размыкатель цепи (Circuit Breaker)
При постоянных сбоях вызовы становятся бесполезными. Размыкатель отсекает трафик на период «реабилитации» сервиса:
const CircuitBreaker = require('opossum');
const breaker = new CircuitBreaker(async (url) => {
const response = await axios.get(url, { timeout: 1000 });
return response.data;
}, {
timeout: 3000, // Слишком долгий ответ ≈ ошибка
errorThresholdPercentage: 50, // % ошибок для размыкания
resetTimeout: 30000, // Через 30 сек — полуоткрытое состояние
rollingCountTimeout: 60000 // Окно статистики – 60 сек
});
// Использование
breaker.fire('https://api.inventory/items')
.then(data => console.log(data))
.catch(err => {
// Открытое состояние? Используем fallback
if (breaker.opened) initiateFallback();
});
Фазы работы:
- Замкнут (Closed): Запросы проходят
- Разомкнут (Open): Запросы немедленно отклоняются
- Полуоткрыт (Half-Open): Пропускается часть трафика для тестирования
Критичный нюанс: Не используйте единый брейкер для всего кластера зависимых сервисов. Сегментируйте их по:
- Конечным точкам API
- Типам ресурсов (чтение/запись)
- Уровням критичности
Паттерн #3: Стратегические Fallbacks
Когда сервис недоступен — не всегда нужно показывать ошибку. Альтернативы:
- Кешированные данные: Вернуть последнюю успешную копию (с пометкой
stale
) - Пустой ответ: Для неключевых данных (например, рекомендаций)
- Деградация функционала: Отключить второстепенные блоки UI
- Асинхронная очередь: Поместить запрос в очередь на фоновую обработку
async function getProductList() {
try {
return await breaker.fire('https://api.products');
} catch (err) {
if (breaker.opened) {
const cached = cacheService.get('last-products');
if (cached) return { ...cached, isStale: true };
}
return { items: [], fallback: true };
}
}
Предостережение: Fallback тоже может выйти из строя. Вводите правила уровня инфраструктуры (например, rate limiter для кеш-сервиса).
Артефакты проектирования
Context Propagation
Передавайте обязательные метаданные цепочки вызовов (requestId, userId, deadline) чтобы:
- Отслеживать сквозную трассировку
- Автоматически отменять устаревшие запросы
// Установка дедлайна на уровне шлюза
axios.get('/api/order', {
headers: {
'X-Request-Deadline': Date.now() + 3000 // Таймаут 3 сек
}
});
// В микросервисе
if (Date.now() > parseInt(req.headers['x-request-deadline'])) {
abortRequest(); // Прерываем долгий процесс
}
Принцип «Пуленепробиваемого стеклянного дома»
Предполагайте, что любой внешний вызов откажет. Документируйте:
- Явные контракты API (схемы ошибок)
- Ожидаемую нагрузку (RPS)
- Статусы здоровья в
/ready
и/live
Изоляция отказов
Используйте bulkheads — разделите ресурсы (пулы потоков, подключения к БД) по бизнес-доменам. Сбой в биллинге не должен парализовать аутентификацию.
Культурные артефакты
- Метрики: Считайте не только ошибки (
5xx
), но и таймауты, открытия брейкеров, состояние fallback. - Постмортемы: Анализируйте триггеры сбоев — изменение в зависимом сервисе? Скачок трафика?
- Хаос-инжиниринг: Планово «убивайте» POD в Kubernetes. Настоящая отказоустойчивость подтверждается в бою.
Итог: Устойчивость ≠ устранение ошибок, а их контролируемая обработка. Цена ошибки — не ее факт, а размер «зараженной» области. Комбинируя ретраи, брейкеры и осознанную деградацию, вы не избегаете штормов, а учитесь в них ходить под парусом.