Элегантная обработка ошибок — визитная карточка зрелого разработчика. В асинхронном мире JavaScript отсутствие стратегии превращает ваш код в мину замедленного действия: падения без логов, незавершенные транзакции, непредсказуемое поведение системы. Рассмотрим практический гид по архитектуре устойчивых приложений.
Почему асинхронные ошибки особенные
Стек вызовов в асинхронных операциях разрывается. Ошибка внутри колбэка setTimeout отвергнутого промиса или забытого await
не всплывает через классический try/catch
. Результат — молчаливый краш вместо контролируемого инцидента.
// Катастрофический антипаттерн
async function fetchData() {
const data = await fetch("/api").then(res => res.json());
// Что если запрос упадет с 500? Или JSON окажется битым?
processData(data);
}
Эволюция обработки: от ада колбэков к промисам
Колбэк-армагеддон:
fs.readFile("config.json", (err, config) => {
if (err) return log(err); // Пропустили return? Дальше код выполнится!
db.connect(config.dbUrl, (err, client) => {
if (err) return log(err);
client.query("SELECT * FROM users", (err, result) => {
if (err) return log(err);
// callback hell
});
});
});
Проблемы: Глубоко вложенные условия, сложная читаемость, риски пропустить return
.
Промисы: прогресс с подводными камнями
fetch("/api/users")
.then(response => response.json())
.then(data => renderUsers(data))
.catch(error => console.error("Fetch failed", error)); // Ловим ошибки сети, но...
Чейн промисов прерывается при первом .catch()
. Упущенное: ловим все ошибки цепочки одним обработчиком, но рискуем:
- Неотлавливаемые исключения вне промисов
- Ошибки в колбэках внутри
.then()
(если не возвращаем промис) - "Проглоченные" ошибки без реакции приложения
Критический нюанс: возвраты в then()
fetch("/api")
.then(response => {
response.json().then(data => { // Вложенный промис без возврата -> риск!
process(data);
});
})
.catch(error => console.log(error)); // Не поймает json() ошибку!
Каждый вложенный промис должен возвращаться наружу:
.then(response =>
response.json().then(data => process(data)) // Теперь цепочка видит ошибки
)
Async/Await: синхронный стиль с асинхронными подвохами
Кажется, что try/catch
решает все:
async function init() {
try {
const user = await getUser();
const posts = await fetchPosts(user.id);
render(posts);
} catch (error) {
console.error("Critical failure", error);
}
}
Но что если:
- Ошибка не должна ломать весь процесс? (например, фоновое обновление)
- Надо точно знать где упало (getUser? fetchPosts?)
- Требуется разная логика обработки для разных ошибок?
Гранулированная обработка без многоэтажных try/catch
Стратегия 1: Изоляция операций
async function loadUserProfile(userId) {
const [user, posts] = await Promise.allSettled([
getUser(userId).catch(error => ({ status: "failed", error })),
getPosts(userId)
]);
if (user.status === "rejected") {
return handleError("USER_LOAD_FAIL", user.reason);
}
if (posts.status === "rejected") {
log.warn("Posts unavailable, continuing");
cache.queueRetry(user.value.id); // Фоновая попытка
}
return { profile: user.value, posts: posts.value || [] };
}
Promise.allSettled()
дает контроль над каждым исходом.
Стратегия 2: Перехват + кастомные ошибки
class NetworkError extends Error { ... }
class ValidationError extends Error { ... }
async function submitForm(data) {
try {
validate(data); // Может выкинуть ValidationError
await api.post("/submit", data);
} catch (error) {
if (error instanceof ValidationError) {
showFormError(error.field);
} else if (error instanceof NetworkError) {
scheduleRetry();
} else {
throw error; // Прокидываем неожиданное
}
}
}
Глобальная страховка: последний рубеж обороны
Браузер:
// Ловим неотловленные промисы (например, забытый .catch())
window.addEventListener("unhandledrejection", event => {
event.preventDefault();
telemetry.trackError(event.reason);
showUserAlert("Unexpected issue. Engineers notified.");
});
// Отлов глобальных ошибок
window.onerror = function(message, source, lineno, colno, error) {
telemetry.log({ message, stack: error?.stack });
};
Node.js:
process.on("unhandledRejection", (reason, promise) => {
log.fatal({ reason, promise }, "Unhandled Rejection");
// process.exit(1) для фатальных сценариев
});
process.on("uncaughtException", error => {
log.fatal(error, "Uncaught Exception");
// Продолжение работы может быть опасным!
// Часто разумно аварийно завершаться после логгирования
});
Инженерные рекомендации практика
- Статус ошибок в ответах API: Явно указывайте статусы типа 4xx/5xx. Не маскируйте 500-е под 200 OK с
{ success: false }
. - Идентификация: Генерируйте
errorId
на сервере для трассировки:
{ "error": "InvalidToken", "message": "Session expired", "errorId": "123e4567" }
- Лог-контекст: Пишите не только
error.message
, но и критичные переменные для диагностики:
log.error({ err, userId, requestId }, "Payment processing failed");
- Контроль потока: Иногда
Promise.reject()
— правильный способ прервать цепочку вместо возврата флагов{ error: ... }
. - Тестирование сбоев: Пишите тесты, имитирующие:
- Отвал сети (nock, jest-fetch-mock)
- Невалидные API-ответы
- Таймауты (sinon.useFakeTimers)
Итоговые принципы
Четкие границы ответственности: Модуль/функция должны явно декларировать, какие ошибки обрабатывают внутри, какие пробрасывают выше.
Максимальная контекстуализация: Ошибка без стека вызовов, параметров запроса и ID пользователя — бесполезна.
Защита против тихого краха: Механизмы глобального перехвата — критичны для production.
Информативность: "Something went wrong" в UI допустимо только в паре с тикетом в системе мониторинга.
Обработка ошибок — это проектирование системы на выживание. Сломанный биллинг-модуль не должен валить весь backend. Авторизованная пользовательская сессия обязана выходить из ошибочного состояния хотя бы через логаут. За одиннадцать лет работы с JavaScript я видел, что грамотная обработка ошибок отделяет системы, которые падают еженощно, от тех, которые десятилетиями обслуживают миллионы.