"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 помогут двигать проект дальше.

Комментарии (16)


  1. Dhwtj
    04.09.2025 06:06

    Библиотека добавляет Result<T, E> паттерн, но:

    • Ошибки всё равно не типизированы — получаешь generic FetchError, а не конкретные NotFound | Unauthorized | NetworkError

    • Нет exhaustive checking — тут нет проверки что обработаны все варианты ошибок

    • Нет композиции ошибок — когда у тебя цепочка вызовов с разными типами ошибок, всё схлопывается в один тип

    Rust через wasm сделал бы так

    // Rust
    match api.get::<Users>("/users").await {
        Ok(users) => ...,
        Err(ApiError::NotFound) => ...,
        Err(ApiError::RateLimit { retry_after }) => ...,
        // компилятор проверит ВСЕ варианты
    }


    1. mayorovp
      04.09.2025 06:06

      Что значит - "Нет exhaustive checking"? А это тогда что?

      export type NormalizedError = NetworkError | TimeoutError | HttpError | ValidationError;
      
      export type SafeResult<T> =
          | { ok: true; data: T; response: Response }
          | { ok: false; error: NormalizedError; response?: Response };
      


      1. Dhwtj
        04.09.2025 06:06

        есть union type, но нет проверки полноты при обработке

        // Сейчас у тебя так (компилятор не поймает если забудешь случай):
        function handleError(error: NormalizedError) {
          if (error.type === 'network') { ... }
          if (error.type === 'timeout') { ... }
          // Забыл HttpError и ValidationError - код скомпилируется!
        }

        Надо хотя бы так, но это костыль

        // Сейчас у тебя так (компилятор не поймает если забудешь случай):
        function handleError(error: NormalizedError) {
          if (error.type === 'network') { ... }
          if (error.type === 'timeout') { ... }
          // Забыл HttpError и ValidationError - код скомпилируется!
        }
        
        // Exhaustive checking:
        function handleError(error: NormalizedError): string {
          switch (error.type) {
            case 'network': return 'Network failed';
            case 'timeout': return 'Timeout';
            case 'http': return `HTTP ${error.status}`;
            case 'validation': return error.message;
            // Если добавишь новый тип в union - TS сломается тут
            default:
              const _exhaustive: never = error;
              throw new Error(`Unhandled error type`);
          }
        }

        Да это не главное. Просто, триггернулся на знакомую проблему.

        P.S. вы, наверное, про такое использование. Да, так прокатит

        // ПРОВЕРИТ - есть return type
        function handleError(error: NormalizedError): string {
          switch (error.type) {
            case 'network': return 'net';
            case 'timeout': return 'time';
            // TS ERROR: Function lacks ending return statement
          }
        }


        1. mayorovp
          04.09.2025 06:06

          Но это же проблема Typescript, а не библиотеки.


  1. 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  // секунда между попытками
    })

    Посмотрел ваш код.

    Как по мне, сложновато и надо разделить ответственности - таймауты отдельно, ретраи отдельно, сигналы отдельно.


    1. 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 закрывает те же задачи, что и ваш клиент, но делает это безопаснее, типобезопаснее и предсказуемо.


      1. 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), хотя можете добавить, если хотите сделать тяжёлую либу

        - В итоге, неудобный средний вариант


        1. 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.


          1. Dhwtj
            04.09.2025 06:06

            По поводу «умных дефолтов» — это осознанное решение: безопасные настройки по умолчанию (идемпотентные ретраи, backoff + jitter), потому что именно на этих «простых» кейсах чаще всего стреляют себе в ногу.

            Прикольно. Ну, хорошо.


  1. ooko
    04.09.2025 06:06

    Раньше использовал похожий объект с {status, result} , вдохновился http. Но в новом коде стал использовать [error, data], практика показала что так удобнее и у других отторжения не вызывает.

    Пробовал вернуться к try/catch, но уж больно удобно разделять ошибки api и исключения компилятора


  1. 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.