javascript

Хватит писать try/catch вокруг fetch: история о том, как я устал ловить ошибки

  • пятница, 5 сентября 2025 г. в 00:00:10
https://habr.com/ru/articles/943732/
"You can't have errors in your code if you wrap the entire codebase in a try/catch block"
"You can't have errors in your code if you wrap the entire codebase in a try/catch block"

Этот мем смешной, пока не осознаешь, что в реальных проектах мы именно так и поступаем. Только заворачиваем не весь код сразу, а каждый HTTP-запрос по отдельности.

Пишешь fetch и рефлекторно добавляешь try/catch. Где-то словил TypeError, где-то таймаут, где-то сервер вернул 500. В итоге половина кода превращается в кашу проверок, а другая половина - в обработчики ошибок.

Я годами так делал, пока не понял: проблема не в том, что мы ловим ошибки. Проблема в том, что fetch заставляет нас их ловить везде и всегда.

Так появилась библиотека @asouei/safe-fetch. Ее задача проста: убрать try/catch из проектов навсегда.

Проблемы, которые достали всех

Помните эту красоту?

try {
  const res = await fetch('/api/users');
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  }
  const data = await res.json();
  // что-то делаем с data
} catch (e) {
  // а тут ловим все подряд: таймауты, 404, проблемы с сетью
  console.error('Что-то пошло не так:', e.message);
}
Мем про обработку ошибок: exception в виде медведя, а try, catch и finally убегают от него
Мем про обработку ошибок: exception в виде медведя, а try, catch и finally убегают от него

Через месяц в проекте половина функций выглядит именно так. А проблемы одни и те же:

  • fetch кидает исключения только на сетевые сбои. 404 и 500 надо ловить руками

  • Нет "общего таймаута" на операцию. Только костыли с AbortController

  • Логика повторов? Пиши сам или тащи тяжелый axios

  • Ошибки не типизированы. В TypeScript приходится гадать что в e.message

Что я хотел получить

Три простые вещи:

1. Никаких throw
Каждый вызов возвращает результат с понятным флагом ok.

2. Нормализованные ошибки
Вместо загадочного e.message - четкие типы: NetworkError, TimeoutError, HttpError, ValidationError.

3. Фишки из коробки
Общий таймаут, умные ретраи, поддержка Retry-After.

Вот как это выглядит:

import { safeFetch } from '@asouei/safe-fetch';

const result = await safeFetch.get<{ users: User[] }>('/api/users');

if (result.ok) {
  console.log(result.data.users);
} else {
  console.error(result.error.name); // NetworkError | TimeoutError | ...
}

Результат всегда предсказуемый: либо { ok: true, data }, либо { ok: false, error }.
Ни одного try/catch в бизнес-логике.

Что под капотом

Двойные таймауты

Можно задать timeoutMs для одной попытки и totalTimeoutMs для всей операции:

const api = createSafeFetch({
  timeoutMs: 5000,        // 5с на попытку
  totalTimeoutMs: 30000   // 30с всего (включая ретраи)
});

Умные ретраи

По умолчанию повторяются только GET и HEAD - это защищает от случайных дубликатов POST-запросов:

const result = await safeFetch.get('/api/flaky', {
  retries: {
    retries: 3,
    baseDelayMs: 300  // экспоненциальный backoff
  }
});

Поддержка Retry-After

Если сервер вернул 429 с заголовком - библиотека сама подождет:

// Сервер: 429 Too Many Requests, Retry-After: 60
// safe-fetch: ждем ровно 60 секунд

Validation без исключений

Можно подключить Zod или другую схему:

const result = await safeFetch.get('/user/123', {
  validate: (data) => UserSchema.safeParse(data).success 
    ? { success: true, data } 
    : { success: false, error: 'Invalid user' }
});

Реальная польза

До: кодовая база из ада

async function getUsers() {
  try {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error(`${res.status}`);
    return await res.json();
  } catch (e) {
    logger.error('Users fetch failed', e);
    throw e; // пробрасываем дальше
  }
}

async function createUser(data) {
  try {
    const res = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(data)
    });
    if (!res.ok) throw new Error(`${res.status}`);
    return await res.json();
  } catch (e) {
    logger.error('User creation failed', e);
    throw e;
  }
}

После: чистый код

const api = createSafeFetch({
  baseURL: '/api',
  interceptors: {
    onError: (error) => logger.error('API error', error)
  }
});

async function getUsers() {
  return api.get<User[]>('/users');
}

async function createUser(data: NewUser) {
  return api.post<User>('/users', data);
}

Весь error handling в одном месте. Никаких дублирующихся проверок.

История из практики

У меня был проект в небольшой команде, где мы работали с несколькими сторонними API. На бумаге всё выглядело просто: дергаем данные, отображаем в интерфейсе. Но реальность быстро всё усложнила.

Что пошло не так:

  • Один сервис периодически отвечал 500-ми ошибками

  • Другой любил возвращать пустые JSON-ы, хотя статус был 200

  • Иногда ответы зависали на десятки секунд, и пользователи жаловались, что «кнопка не работает»

В итоге код превратился в хаос из try/catch, таймеров с AbortController и кучи логов вроде «Request failed again». Мы даже обсуждали идею тащить axios, хотя никто не горел желанием добавлять ещё одну тяжелую зависимость.

В какой-то момент я собрался и сказал: «Хватит. Мы тратим больше времени на ловлю ошибок, чем на фичи». Так появился safe-fetch.

После перехода:

  • Весь error handling уехал в interceptors - стало понятно, где искать баги

  • Ретраи на GET реально спасли от флейки API (раньше мы просто рефрешили страницу)

  • Общий таймаут избавил от «вечных» спиннеров, когда пользователь ждал ответа, который никогда не придёт

  • В логах наконец появились внятные названия ошибок (NetworkError, TimeoutError), а не загадочные «undefined»

Через пару недель мы заметили, что больше вообще не пишем try/catch вокруг запросов. И это стало огромным облегчением для всей команды.

Сравнение с конкурентами

Фича

safe-fetch

axios

ky

fetch

Размер

~3kb

~13kb

~11kb

0kb

Безопасные результаты

Типизированные ошибки

Общий таймаут

Retry-After

Zod-ready

Установка и первые шаги

npm install @asouei/safe-fetch

Базовый пример:

import { safeFetch } from '@asouei/safe-fetch';

const users = await safeFetch.get<User[]>('/api/users');
if (users.ok) {
  console.log(users.data);
} else {
  console.error(users.error.name, users.error.message);
}

Для больших проектов:

import { createSafeFetch } from '@asouei/safe-fetch';

const api = createSafeFetch({
  baseURL: 'https://api.example.com',
  headers: { 'Authorization': `Bearer ${token}` },
  timeoutMs: 10000,
  retries: { retries: 2 }
});

Для кого это

  • Команды, уставшие от непредсказуемых ошибок и дублирующего кода

  • Проекты с жесткими SLA, где важны таймауты и ретраи

  • TypeScript-кодбазы, где нужна точная типизация ошибок

  • Разработчики, которые хотят простоту fetch с production-готовностью

Что дальше

Библиотека уже готова к продакшену. В планах:

  • ESLint правила для паттерна { ok }

  • Готовые адаптеры для React Query и SWR

  • Примеры для Next.js и Cloudflare Workers

Заключение

Вообще я создавал эту штуку для себя, чтобы самому было легче. Но вскоре понял, что она может быть полезна каждому - решил поделиться.

safe-fetch не пытается заменить axios или ky. Она решает одну задачу: делает fetch безопасным и предсказуемым. Никаких революций - просто убирает ту ежедневную боль, с которой мы все смирились.

Может, вы тоже устали объяснять джунам, почему нужно проверять res.ok? Или писать одинаковые обработчики ошибок в каждом API-методе? Если да - попробуйте. Возможно, через неделю вы уже не захотите возвращаться к старым паттернам.

А если найдете баги или захотите что-то улучшить - буду рад увидеть в Issues. В конце концов, эта библиотека родилась из реальных проблем, и лучше всего она растет от реального фидбека.

  • 🌟 Библиотека добавлена в Awesome TypeScript — один из крупнейших мировых списков лучших TypeScript-проектов


Попробовать самому:

P.S. Если статья была полезна - звезда в репозитории и ваш фидбек в Issues помогут двигать проект дальше.