Тихий сбой платежа из-за неперехваченного исключения. Висящий индикатор загрузки после сетевого сбоя. Поломанная бизнес-логика из-за частично выполненной цепочки промисов. Неадекватная обработка асинхронных ошибок — бич современных веб-приложений, порождающий самые коварные баги. Когда 89% вызовов API в типичных SPA-приложениях обрабатывают ошибки неправильно или неполно, проблема требует системного решения.
Рассмотрим типичный антипаттерн:
async function fetchData() {
const response = await fetch('/api/data');
const data = await response.json();
return process(data);
}
// Где-то в коде:
fetchData().then(updateUI);
Беглый взгляд не обнаруживает явных проблем, но эта реализация содержит три фатальных упущения:
- Не обрабатывает HTTP-ошибки (404, 500)
- Игнорирует исключения в
process(data)
- Оставляет необработанным отклоненный промис в точке вызова
Контроль состояния ответа HTTP
Первая ловушка — предположение, что fetch
автоматически отклоняет промис при HTTP-ошибках. В реальности:
const response = await fetch('https://api.example/404');
console.log(response.ok); // false
console.log(response.status); // 404
Промис разрешится, но response.ok
будет false для статусов ≥400. Обязательная проверка:
async function safeFetch(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
const error = new Error(`HTTP Error ${response.status}`);
error.response = response; // Сохраняем контекст
throw error;
}
return response;
}
Каскадное поглощение ошибок
Самой опасной особенностью асинхронного кода является неявное подавление исключений. Решение — композиция обработчиков с явным пробрасыванием:
class ApiError extends Error {
constructor(message, { url, status, context }) {
super(message);
this.name = 'ApiError';
this.details = { url, status, context };
}
}
async function fetchOrder(orderId) {
try {
const response = await safeFetch(`/api/orders/${orderId}`);
const data = await parseJSON(response);
return validateOrderSchema(data);
} catch (error) {
if (error instanceof SyntaxError) {
throw new ApiError('Invalid JSON', {
url: `/api/orders/${orderId}`,
status: response.status,
context: { orderId }
});
}
throw error; // Пробрасываем оригинальную ошибку
}
}
Создание специализированных классов ошибок с контекстом упрощает последующую обработку и логирование.
Транзакционность асинхронных операций
Распространенная ошибка — частичное выполнение последовательных операций. Пример ненадежной реализации:
async function transferFunds(source, target, amount) {
await withdraw(source, amount);
await deposit(target, amount); // Может провалиться после успешного списания
}
Решение через транзакционный подход:
async function transferFunds(source, target, amount) {
let committed = false;
try {
const withdrawResult = await withdraw(source, amount);
const depositResult = await deposit(target, amount);
committed = true;
return { withdrawResult, depositResult };
} finally {
if (!committed) {
await rollbackWithdraw(source, amount);
}
}
}
Интеграция с системами мониторинга
Перехват глобальных необработанных обещаний критичен для продакшн-систем:
// В Node.js
process.on('unhandledRejection', (reason, promise) => {
sentry.captureException(reason, { extra: { promise } });
});
// В браузере
window.addEventListener('unhandledrejection', event => {
event.preventDefault();
trackError(event.reason);
});
Но глобальные обработчики — последняя линия обороны. Предпочтение следует отдавать локальным перехватчикам с контекстным логированием.
Прерываемые операции
С появлением AbortController управление жизненным циклом асинхронных операций стало обязательным навыком:
const controller = new AbortController();
async function search(query) {
try {
const response = await fetch(`/search?q=${query}`, {
signal: controller.signal
});
// Обработка результатов
} catch (error) {
if (error.name === 'AbortError') {
console.log('Search aborted');
return;
}
throw error;
}
}
// Прервать при новом запросе:
function handleSearchInput(query) {
controller.abort();
search(query);
}
Реактивное управление состоянием ошибок
В современных SPA-фреймворках централизованная обработка ошибок требует интеграции с состоянием приложения. Пример для React:
const ErrorContext = createContext();
function ErrorBoundary({ children }) {
const [error, setError] = useState(null);
const handleReset = () => setError(null);
return (
<ErrorContext.Provider value={{ error, setError }}>
{error ? (
<RecoveryUI error={error} onRetry={handleReset} />
) : (
<ErrorBoundaryImpl onError={setError}>{children}</ErrorBoundaryImpl>
)}
</ErrorContext.Provider>
);
}
// Использование в хуках:
function useAsync(fn) {
const { setError } = useContext(ErrorContext);
useEffect(() => {
fn().catch(error => {
setError(error);
});
}, [fn, setError]);
}
Заключение
Три ключевых правила надежной обработки асинхронных ошибок:
- Всегда предполагайте неопределенность внешних систем
- Сохраняйте максимальный контекст для диагностики
- Проектируйте композитные операции как атомарные транзакции
Инструменты выросли в возможностях — от прозрачных AbortController до продвинутых TypeScript-типов для discriminated union результатов операций. Но фундаментальные принципы остаются: явность обработки, полнота контекста и стратегическое планирование сценариев отказа. Следующий шаг — интеграция автоматических валидаторов типа ESLint-plugin-promise, но важно помнить — никакие тулы не заменят ревью кода, где проверяют сценарии "а что если тут всё пойдёт не так?".