
Этот мем смешной, пока не осознаешь, что в реальных проектах мы именно так и поступаем. Только заворачиваем не весь код сразу, а каждый 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);
}

Через месяц в проекте половина функций выглядит именно так. А проблемы одни и те же:
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 помогут двигать проект дальше.
Комментарии (16)
Dhwtj
04.09.2025 06:06Для вашего случая (JS /TS, конкретные жалобы) я бы настрогал что-то такое
type ApiConfig = { baseURL: string timeout?: number retries?: number retryDelay?: number onError?: (error: ApiError, context: RequestContext) => void } type RequestContext = { method: string path: string attempt: number duration: number } class ApiClient { constructor(private config: ApiConfig) { // По умолчанию молча в лог this.config.onError ??= (error, ctx) => { console.error(`[API] ${ctx.method} ${ctx.path}`, { error, attempt: ctx.attempt, duration: ctx.duration }) } } async request<T>(method: string, path: string, body?: unknown): Promise<Result<T>> { const startTime = Date.now() const maxRetries = this.config.retries ?? 3 const retryDelay = this.config.retryDelay ?? 500 for (let attempt = 1; attempt <= maxRetries; attempt++) { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), this.config.timeout ?? 5000) try { const res = await fetch(this.config.baseURL + path, { method, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, headers: body ? { 'Content-Type': 'application/json' } : {} }) clearTimeout(timeoutId) // 500-ки - ретраим if (res.status >= 500 && attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay * attempt)) continue } // Пустой JSON при 200 const text = await res.text() if (res.ok && !text.trim()) { const error: ApiError = { kind: 'EmptyBody' } this.config.onError?.(error, { method, path, attempt, duration: Date.now() - startTime }) return { ok: false, error } } if (!res.ok) { const error = this.mapStatusToError(res.status, text) this.config.onError?.(error, { method, path, attempt, duration: Date.now() - startTime }) return { ok: false, error } } const data = JSON.parse(text) return { ok: true, data } } catch (e) { if (attempt === maxRetries) { const error: ApiError = e.name === 'AbortError' ? { kind: 'Timeout' } : { kind: 'Network', cause: e } this.config.onError?.(error, { method, path, attempt, duration: Date.now() - startTime }) return { ok: false, error } } await new Promise(r => setTimeout(r, retryDelay * attempt)) } } // Не должно сюда попасть, но TS требует return { ok: false, error: { kind: 'Network', cause: 'Max retries' }} } private mapStatusToError(status: number, text: string): ApiError { switch(status) { case 404: return { kind: 'NotFound' } case 401: return { kind: 'Unauthorized' } case 400: return { kind: 'BadRequest', message: text } default: return { kind: 'Network', cause: status } } } }
Использование
// Использование const api = new ApiClient({ baseURL: 'https://api.example.com', timeout: 10000, retries: 3, // По умолчанию молча в лог, но можем переопределить onError: (error, ctx) => { console.error(`API: ${error.kind}`, ctx) if (error.kind === 'Timeout') { showToast('Сервер не отвечает') } } }) // Типизированные методы async function getUsers(): Promise<Result<User[]>> { return api.request<User[]>('GET', '/users') } async function createUser(data: UserData): Promise<Result<User>> { return api.request<User>('POST', '/users', data) } async function deleteUser(id: string): Promise<Result<void>> { return api.request<void>('DELETE', `/users/${id}`) } // В компоненте React function UserList() { const [users, setUsers] = useState<User[]>([]) useEffect(() => { loadUsers() }, []) async function loadUsers() { const result = await getUsers() if (result.ok) { setUsers(result.data) } // Ошибки уже в логе через onError } async function handleDelete(id: string) { const result = await deleteUser(id) if (result.ok) { await loadUsers() // перезагружаем список } else if (result.error.kind === 'Unauthorized') { // Только критичные ошибки обрабатываем явно redirectToLogin() } // Остальное молча ушло в лог } async function handleCreate(data: UserData) { const result = await createUser(data) if (!result.ok) { // BadRequest показываем юзеру if (result.error.kind === 'BadRequest') { showValidationError(result.error.message) return } } else { await loadUsers() closeModal() } } } // Для проблемного сервиса - отдельный клиент const flakyApi = new ApiClient({ baseURL: 'https://flaky-service.com', timeout: 30000, // 30 сек для медленного сервиса retries: 5, // больше попыток для 500-ок retryDelay: 1000 // секунда между попытками })
Посмотрел ваш код.
Как по мне, сложновато и надо разделить ответственности - таймауты отдельно, ретраи отдельно, сигналы отдельно.
Asouei Автор
04.09.2025 06:06По сути вы собрали свой мини-axios: таймаут на одну попытку, линейные ретраи, ручной JSON-парсинг. Я собрала
@asouei/safe-fetch
ровно для того, чтобы такие вещи не приходилось писать заново и чтобы они работали безопаснее.В вашем коде есть несколько проблемных мест:
– Нет total timeout: вы прерываете только отдельную попытку, но вся операция с ретраями может зависнуть навсегда. У меня естьtotalTimeoutMs
, который гарантированно обрывает всю цепочку.
– Ретраи POST: у вас повторяются любые 5xx, и POST может уйти дважды → дублирование сайд-эффектов. Вsafe-fetch
по умолчанию ретраятся только идемпотентные методы (GET/HEAD).
– Retry-After: ваш клиент его игнорирует, вы стучитесь в закрытую дверь. У меня заголовок учитывается, и пауза ровно такая, как просит сервер.
– JSON-парсинг:JSON.parse
у вас бросает исключение и попадает в catch как «Network». У меня не кидается — возвращаетсяnull
, а строгая проверка делается черезvalidate
.
– Content-Type: вы всегда парсите JSON, даже если сервер вернулtext/csv
. Вsafe-fetch
это проверяется.
– Backoff: у вас линейныйretryDelay * attempt
, у меня экспоненциальный с джиттером и верхним капом.
– Ошибки: вашиkind
не типизированы. У меня дискриминированный union (NetworkError | TimeoutError | HttpError | ValidationError
) +errorMap
для доменных (NotFoundError
,AuthError
и т.д.).В результате:
@asouei/safe-fetch
закрывает те же задачи, что и ваш клиент, но делает это безопаснее, типобезопаснее и предсказуемо.Dhwtj
04.09.2025 06:06- Total timeout - да, важно
- Retry POST - критично, может дублировать заказы/платежи
- Retry-After - стоит учитывать
- Content-Type проверка - нужна
Хотя, не увидел этого в перечисленных вами в статье проблемах.
"errorMap для доменных" - это уже бизнес-логика, не всегда нужна в базовом клиенте
в safe-fetch:
- Слишком много "умных" дефолтов не к месту (автоматический retry policy по методам), а вот дефолт записи в лог я бы сделал, с возможностью перенаправить на консоль и т.п.
- JSON.parse возвращает null вместо ошибки - скрывает проблемы
- Навязывает свою модель ошибок
И в целом у вас
Классическое противоречие библиотек:
Узкая проблема:
- "Безопасный fetch с ретраями" - казалось бы, простая задача
- 50-100 строк кода решают 80% кейсов
Но раздуто:
- errorMap для доменных ошибок (это уже бизнес-логика)
- validate с кастомными схемами ( если я правильно понял)
- Умные дефолты по HTTP методам
- Экспоненциальный backoff с джиттером (overengineering для большинства)
Получается:
- Для простых кейсов избыточна
- Для сложных недостаточна (нет streaming, progress, cancel groups, request queuing), хотя можете добавить, если хотите сделать тяжёлую либу
- В итоге, неудобный средний вариант
Asouei Автор
04.09.2025 06:06Да, согласен, в статье я перечислил не все боли.
В
safe-fetch
эти пункты есть:
– Total timeout (totalTimeoutMs
), чтобы не зависнуть навсегда.
– POST не ретраится по умолчанию, чтобы не дублировать заказы/платежи.
– Retry-After учитывается (и секунды, и дата).
– Content-Type проверяется перед JSON-парсингом.По поводу «умных дефолтов» — это осознанное решение: безопасные настройки по умолчанию (идемпотентные ретраи, backoff + jitter), потому что именно на этих «простых» кейсах чаще всего стреляют себе в ногу. Но всё это можно отключить:
retries: false
,timeoutMs: 0
,errorMap: e => e
.JSON возвращает
null
не для того, чтобы спрятать ошибку, а чтобы не кидать исключения. Хочешь строгого поведения — указываешьvalidate
и получаешьValidationError
.Что касается «навязывания модели ошибок» — базовый union (
Network | Timeout | Http | Validation
) минимален, дальше черезerrorMap
можно раскрасить в любые доменные ошибки.Так что задача либы — дать безопасную базу на ~3kb, а сложные вещи вроде стриминга или очередей можно навесить сверху, не таща в проект ещё один axios.
Dhwtj
04.09.2025 06:06По поводу «умных дефолтов» — это осознанное решение: безопасные настройки по умолчанию (идемпотентные ретраи, backoff + jitter), потому что именно на этих «простых» кейсах чаще всего стреляют себе в ногу.
Прикольно. Ну, хорошо.
ooko
04.09.2025 06:06Раньше использовал похожий объект с
{status, result}
, вдохновился http. Но в новом коде стал использовать[error, data]
, практика показала что так удобнее и у других отторжения не вызывает.Пробовал вернуться к try/catch, но уж больно удобно разделять ошибки api и исключения компилятора
pursuit
04.09.2025 06:06Это мне напомнило, как я когда-то для своего проекта хотел внести обработку ошибок из Go. И поэтому написал мини либу: https://gist.github.com/lshegay/c4298afd7425c5449d60daefc3305fca
Правда, есть и минус в таком подходе. В особенности касается и
safeFetch
из данного поста. По запаре можешь начать прокидывать необработанный объект с ok, data/error в функцию в качестве аргумента, где ожидается, например, дженерик с объектом.Это связано с тем, что вот такая запись:
const users = await safeFetch.get<User[]>('/api/users');
НЕ подразумевает необходимость обработать users и также НЕ подразумевает, что надо использовать не
users
, аusers.data
. Из-за чего начинаешь придумываешь штуки типаusersResult
. Хотя это и болезнь axios в целом.Можно еще раскрывать объект, например,
const { data: users, ok: okUsers, error: errorUsers } = await safeFetch
, но тут уже глазу неприятно становится.Возможно, в теории, можно попробовать возвращать не объект, а кортеж типа
const [users, errorUsers] = await safeFetch...
, и в целом избавиться отok
, а проверятьerror
наnull
.
Dhwtj
Библиотека добавляет Result<T, E> паттерн, но:
Ошибки всё равно не типизированы — получаешь generic FetchError, а не конкретные NotFound | Unauthorized | NetworkError
Нет exhaustive checking — тут нет проверки что обработаны все варианты ошибок
Нет композиции ошибок — когда у тебя цепочка вызовов с разными типами ошибок, всё схлопывается в один тип
Rust через wasm сделал бы так
mayorovp
Что значит - "Нет exhaustive checking"? А это тогда что?
Dhwtj
есть union type, но нет проверки полноты при обработке
Надо хотя бы так, но это костыль
Да это не главное. Просто, триггернулся на знакомую проблему.
P.S. вы, наверное, про такое использование. Да, так прокатит
mayorovp
Но это же проблема Typescript, а не библиотеки.