Все потоки
Поиск
Написать публикацию
Обновить

Комментарии 41

Библиотека добавляет 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 }) => ...,
    // компилятор проверит ВСЕ варианты
}

Что значит - "Нет 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 };

есть 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
  }
}

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

А этого нейросеть не показала :)

Есть правило линтера - "exhaustive switch" или что-то в этом роде, как раз для явного перебора возможных значений в TS. Так что это проблема вызывающей стороны, да.

Для вашего случая (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  // секунда между попытками
})

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

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

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

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

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

Да, согласен, в статье я перечислил не все боли.

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

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

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

Мы реально дошли до того, чтобы нейронками дискутировать в комментах с обоих сторон диалога? :(

Такое ощущение, что вебдев первым обкатывает все (в том числе и негативные) тенденции в айти. За счёт короткого "жизненного цикла" разработки. И первыми плотно сели на ai-иглу.

Читал комменты и думал: неужели правда друг другу комментарии генерируют, может мне все же показалось и я один так вижу? Не один:)

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

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

Это мне напомнило, как я когда-то для своего проекта хотел внести обработку ошибок из 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.

Спасибо за фидбек 🙌 Да, про «можно случайно передать не .data, а весь результат» - это верно, но тут как раз спасает TS: safeFetch.get<User[]> возвращает union, и напрямую в функцию, где ждут User[], его не передашь.

Согласен, что ergonomics можно упростить. Думаю добавить tuple-вариант:

const [users, err] = await safeFetch.tuple<User[]>('/api/users');

и ещё ESLint-правило, чтобы не забывали проверять .ok.

Спасибо за статью, прикольная идея)

Идея прикольная, но по-хорошему, то что библиотека пытается фиксить случаи где бек то пустой 200 отправит, то ещё какую хрень - это перекладывание ответственности с бека на клиент. Если бек падает, или полон багов, это не на фронтенде чинить надо (хотя покажите мне проект где фронтендерам не приходилось это чинить xD)

Тут важно разделять «чинить баг на бэке» и «не уронить фронт у пользователя». Конечно, если бек шлёт 200 с пустым телом или ломает JSON — это баг сервиса, и фиксить его надо там. Но реальность такая, что фронтенд вынужден с этим сталкиваться каждый день (все видели проекты, где это приходилось чинить костылями).

safe-fetch как раз не «перекладывает ответственность», а даёт безопасный слой: ошибки нормализуются, JSON-парсинг не падает в рантайме, ретраи с Retry-After не забивают сервер. Это не замена фиксу на бекенде, а страховка для фронта, чтобы у пользователя не превращалось всё в белый экран.

Если бек идеальный — либу можно и не подключать. Но пока мы живём в мире неидеальных API, проще один раз вынести эту логику в маленький клиент, чем писать сотни try/catch по коду.

Бэк может (должен) стоять за api gateway и edge вебсервером, которые могут поломать прекрасный ответ бэка. Например тот же Cloudflare легко может заменить ответ на html. Да и у пользователей может висеть какой-то корпоративный прокси. Или wifi точка внезапно отдаст html. Не доверяйте никому, даже себе ;)

Вот, да 👍 Даже если бэк идеальный, по пути всё равно может встрять Cloudflare, прокси или вайфай-роутер и подсунуть html вместо json. safe-fetch как раз и нужен, чтобы фронт от этого не падал у юзера.

Кстати у Клауда в последнее время проблемы с PUT и PATCH - он отдает пустой ответ вместо ответа от сервера.

Без него все работает. С POST ,на те же посты проблем нет

Фронт как минимум должен обработать ошибку и показать пользователю понятное уведомление - "Время ожидания ответа от сервера истекло.", "Запись не найдена.", "Что-то пошло не так." и т.д. и т.п.

"Что-то пошло не так."

Очень понятное уведомление, да.

Лучше вообще пользователю ничего не говорить - отличный вариант.


Меньше знает - крепче спит : )

Вот здравая же рецензия, кто те ....или, кто заминусовал.

Почему 13кб это много?

Сам по себе axios не «огромный», но он тащит за собой много лишнего кода: старые фичи, XHR-совместимость, трансформации и т.д

А что если я не любитель получать ответ через await, а любитель then/catch? Мне в then проверять ok и бросать throw? Как-то не круто

safe-fetch не запрещает then/catch. 🙂
Можно работать и так:

safeFetch.get<User[]>('/api/users')
  .then(r => r.ok ? r.data : Promise.reject(r.error))
  .then(users => render(users))
  .catch(err => toast(err.message));

А если хочется именно чтобы бросало — есть unwrap:

unwrap(safeFetch.get<User[]>('/api/users'))
  .then(users => render(users))
  .catch(err => toast(err.message));

То есть хочешь — работаешь без try/catch, хочешь — с классическим throw.

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;
  }
}

А вы действительно так код писали?

Пример специально показан «в лоб», чтобы было понятно, с чем именно safe-fetch помогает. В реальном коде никто не пишет по 10 try/catch на каждый запрос — и вот как раз для этого я и сделал библиотеку. Она убирает всю эту рутину и превращает код в:

const res = await safeFetch.get<User[]>('/api/users');
if (res.ok) render(res.data);
else logger.error(res.error);

То есть идея не в красоте примера, а в том, чтобы таких простыней больше не писать руками.

Костылил подобную тулзу для своих хобби-проектов, удивительно насколько яваскрипт плох "из коробки".

Мне по нраву rxjs + angular http. В случае удаления компонента или быстрых изменений можно легко отменять предыдущий запрос и генерировать новый

Спасибо за кропотливый труд создания публичной либы! Все эти приседания с github, npm, docs, adapters for frameworks, etc...

Очень много надо бюрократии сотворить, чтобы просто либу приняли миром 🤝

Было бы совсем классно, если чуть подправить имена переменных до более стандартных англоязычных: не ok, а success, не retries.retries, а retries.count и так далее.

Думаю сегодня займусь!

хех, помнится работал я с одним api, так там вполне норм было отдать ответ вида

[
{"key_column": str, "value": str},
{"key_column": str },
{"key_column": str, "value": list[dict]},
{"key_column": str, "value": dict}
]

А разве все это не решается интерсептором для парсинга ответа и выкидыванием ошибки определенного класса Clietn/Server error. Обычно над голым фетчом есть какой то апи клиент, где все это реализуется, в том числе ретраи и прочее. Хотя если задача библиотеки написать свой аксиос на базе фетча, наверное это имеет смысл

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации