В экосистеме JavaScript, где операции ввода-вывода доминируют, асинхронный код стал краеугольным камнем. Однако разработчики часто сталкиваются с проблемой, когда неожиданная ошибка в цепочке промисов или async/await приводит к критическому сбою приложения. Рассмотрим, почему код вида fetchData().then(render).catch(logError)
может быть недостаточным, и как проектировать устойчивые системы.
Типовые провалы
1. Молчаливое проглатывание ошибок
async function updateUserProfile(userId) {
const data = await fetch(`/api/users/${userId}`);
// Ошибка: нет try/catch. При падении fetch исключение распространится за пределы функции
const json = await data.json();
return json;
}
Проблема: Неперехваченные rejection'ы промисов вызывают событие unhandledRejection
в Node.js. В браузере это приводит к расплывчатым сообщениям в консоли, маскирующим коренную причину.
2. Иллюзия обработки в Promise.all
Promise.all([queryDatabase(), fetchExternalService()])
.then(([dbResult, apiResult]) => {
// Один сбой отклоняет весь Promise.all
})
.catch(e => console.error('Что-то сломалось'));
Последствия: При параллельном выполнении задач потеря контекста сбоя делает отладку игрой в угадайку: «Это СУБД не ответила или API вернул 500?».
3. Смертельные микротаски
function initApp() {
initializeAnalytics() // Возвращает промис
.then(() => console.log('Analytics up'));
// Пропущенный catch: необработанный rejection убьёт процесс Node.js
}
Эффект: В Node.js процесс завершится с кодом 1 при необработанной ошибке, даже если приложение могло продолжать работу.
Инженерные стратегии
Контекстное логгирование с метаданными
class DatabaseError extends Error {
constructor(query, params, message) {
super(message);
this.query = query; // Контекст операции
this.params = params; // Параметры для воспроизведения
}
}
async function getUser(id) {
try {
return await db.query('SELECT * FROM users WHERE id = ?', [id]);
} catch (e) {
throw new DatabaseError(
'SELECT * FROM users WHERE id = ?',
[id],
`User lookup failed: ${e.message}`
);
}
}
Зачем: Без контекста ошибка «Connection timeout» бесполезна. Добавление параметров запроса, стеков вызовов и типа операции позволяет воспроизвести сценарий.
Декларативная обработка в Promise.allSettled
const [userResult, auditResult] = await Promise.allSettled([
getUser(userId),
logAuditEvent('PROFILE_VIEW', userId)
]);
const errors = [userResult, auditResult]
.filter(r => r.status === 'rejected')
.map(r => r.reason);
if (errors.length > 0) {
await sendErrorReport(errors); // Отправка всех ошибок разом
if (userResult.status === 'rejected') throw userResult.reason;
}
Преимущество: Частичный сбой не блокирует выполнение. Решение о фатальности ошибки принимается явно, а не делегируется механизму промисов.
Паттерн Circuit Breaker для внешних вызовов
const circuit = new CircuitBreaker(async (url) => {
const res = await fetch(url);
if (res.status >= 500) throw new Error(`HTTP ${res.status}`);
return res.json();
}, {
timeout: 1000, // Автоматический отказ при задержке
threshold: 5, // Максимум ошибок до разрыва цепи
resetPeriod: 30000 // Время до попытки восстановления
});
async function safeFetch(url) {
try {
return await circuit.fire(url);
} catch (e) {
if (e === CircuitBreaker.OPEN) { // Обход заблокированных вызовов
return cachedVersion();
}
throw e;
}
}
Зачем: Защита от каскадных сбоев при отказе внешних сервисов. Автоматическое восстановление после таймаута предотвращает ручной перезапуск.
Интеграционные точки
Глобальные обработчики как последняя линия обороны
// Node.js
process.on('unhandledRejection', (reason, promise) => {
metrics.increment('unhandled_rejection'); // Мониторинг
logger.fatal({ reason, promise }, 'UNHANDLED REJECTION');
// Не завершать процесс явно: modern Node.js (v15+) делает это с кодом 1
});
// Браузер
window.addEventListener('unhandledrejection', event => {
event.preventDefault(); // Подавление стандартного вывода
trackError(event.reason);
if (isCritical(event.reason)) {
showErrorPage(event.reason);
}
});
Предостережение: Глобальные обработчики — это экстренный клапан, а не замена локальному try/catch. Их задача — логировать неизвестные сбои и завершать работу предсказуемо.
Заключение
Обработка асинхронных ошибок — это проектирование системы, а не добавление последусловий. Ключевые практики:
- Контекстные ошибки с метаданными вместо строковых сообщений
- Явное управление частичными сбоями через
Promise.allSettled
- Глобальные обработчики как механизм observability, а не восстановления
- Шаблоны устойчивости (Circuit Breaker, Retry с экспоненциальной задержкой)
Интеграция этих методов снижает количество инцидентов, где пользователь видит «Something went wrong», а разработчик — туманную ошибку без стека. Тестируйте сценарии отказа так же тщательно, как happy path: запускайте Chaos Monkey в staging-окружении, имитируйте обрыв сети и таймауты. Устойчивость — это не отсутствие ошибок, а предсказуемое поведение при их возникновении.