БлогУслугиКарьера
Обсудить проект
БлогУслугиКарьераОбсудить проект
Автоматизация

Настройка API на сайте: 7 ошибок, которые вылезают только в проде

Как настроить API на сайте: где хранить ключи, таймауты, ретраи, идемпотентность и вебхуки. Разбор ошибок, которые на тесте не видно, и чек-лист перед выкаткой.

Редакция Feature
Редакция Feature·
22 авг
·
13 мин
·
Настройка API на сайте: 7 ошибок, которые вылезают только в проде

Пятница, 18:40. Форма заявки на сайте отвечает «Спасибо, мы свяжемся с вами». Менеджер открывает CRM — там пусто со вчерашнего вечера. Тридцать заявок ушли в никуда, потому что у партнёрского API сменился адрес одного эндпоинта, а на стороне сайта никто не проверял, что вернул сервер.

Интеграция при этом «работала». Она прошла ручное тестирование, отдала статус 200 на демо-стенде и была принята заказчиком. Провалилась она только в проде. Молча.

Настройка API на сайте почти никогда не ломается на этапе «отправить запрос». Ломается всё то, что вокруг: хранение ключа, поведение при таймауте, повторные запросы, разбор ответа, приём вебхуков. Ниже — семь мест, где это происходит чаще всего, с кодом и объяснением, почему на тесте их не видно.

Что вообще значит «настроить API на сайте»

Под одной фразой прячутся три разные задачи, и путать их дорого.

Сайт ходит в чужой API. Отправляет заявку в CRM, забирает остатки из 1С, считает доставку через СДЭК, создаёт платёж. Это самый частый сценарий, и дальше речь в основном про него.

Сайт отдаёт свой API. Мобильному приложению, партнёру, внутренней админке. Здесь добавляются аутентификация, версионирование и лимиты на стороне сервера.

Сайт принимает вебхуки. Банк, платёжный шлюз или CRM сами стучатся к вам, когда что-то изменилось. Инициатива на их стороне, и правила игры другие.

Технически это всё HTTP-запросы. Организационно — разные зоны ответственности и разные способы всё сломать.

Ошибка 1. Ключ доступа лежит во фронтенде

Самая частая и самая дорогая. Разработчик спешит, кладёт токен в переменную окружения и обращается к чужому API прямо из браузера.

В Next.js ловушка выглядит безобидно:

// ❌ NEXT_PUBLIC_ означает «вшить в JS-бандл и отдать всем»
const res = await fetch("https://crm.example.com/v2/leads", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_CRM_TOKEN}` },
  body: JSON.stringify(lead),
});

Префикс NEXT_PUBLIC_ — не пометка «неважный». Это прямая инструкция сборщику подставить значение в клиентский код. Токен окажется в бандле, который сможет открыть любой посетитель через DevTools. Дальше — чужие заявки в вашей CRM, исчерпанная квота или счёт за платный сервис.

Правильный вариант — прокси через собственный маршрут. Ключ остаётся на сервере, браузер о нём не знает:

// app/api/crm/lead/route.ts
export async function POST(req: Request) {
  const lead = await req.json();

  const res = await fetch("https://crm.example.com/v2/leads", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CRM_TOKEN}`, // без NEXT_PUBLIC_
      "Content-Type": "application/json",
    },
    body: JSON.stringify(lead),
    signal: AbortSignal.timeout(8000),
  });

  if (!res.ok) {
    return Response.json({ error: "upstream_failed" }, { status: 502 });
  }

  return Response.json(await res.json());
}

Бонусом прокси решает вопрос с CORS: чужой сервер не обязан разрешать запросы с вашего домена, а свой — обязан по определению.

Проверка на пять секунд: откройте продакшен-сайт, нажмите Ctrl+U и поищите в исходниках key, token, secret. Если что-то нашлось, ключ уже скомпрометирован, его нужно отзывать, а не прятать.

Ошибка 2. Запрос без таймаута

fetch не имеет таймаута по умолчанию. Совсем. Если чужой сервер принял соединение и замолчал, запрос будет висеть, пока его не оборвёт платформа.

На тесте это незаметно: локальный стенд отвечает за 40 миллисекунд. В проде партнёрский сервис однажды начинает отвечать за 30 секунд, и последствия расходятся кругами. На serverless-хостинге функция досиживает до лимита и падает по времени. На обычном сервере зависшие запросы копятся и съедают пул соединений. Пользователь всё это время смотрит на крутящийся спиннер и уходит.

// AbortSignal.timeout — нативный способ, без внешних зависимостей
const res = await fetch(url, {
  signal: AbortSignal.timeout(8000), // бросит TimeoutError через 8 секунд
});

Ориентиры по срокам: запрос, от которого зависит отрисовка страницы, — 3–5 секунд. Фоновая отправка в CRM укладывается в 8–10. Выгрузка большого каталога может занять до 30 секунд, но её вообще нельзя делать в HTTP-запросе от пользователя, ей место в очереди.

Отдельно стоит закладывать таймаут короче, чем лимит вашей платформы. Если у хостинга потолок 10 секунд, ставьте 8: тогда вы получите понятную ошибку и успеете её обработать, а не безымянное падение по времени.

Нужно подключить API к сайту?

Спроектируем и настроим интеграцию, которая переживёт продакшен

Обсудить интеграцию

Ошибка 3. Ретраи, которые добивают чужой сервис

Логика «не получилось — повторим» кажется очевидной. В реальности наивный повтор превращает мелкий сбой у партнёра в полноценную аварию.

Два правила, которые нарушают чаще всего.

Не повторять то, что не изменится. Ответ 400 означает, что запрос сформирован неправильно. Ответ 401 — что ключ недействителен. Ответ 422 — что не прошла валидация. Повторять их бессмысленно: партнёр получит три одинаковых неверных запроса вместо одного. Повторять имеет смысл только сетевые ошибки, таймауты, 5xx и 429.

Разводить попытки во времени. Если чужой сервис лежит, все ваши клиенты получат ошибку одновременно и одновременно же пойдут на повтор через фиксированную секунду. Сервис поднимется и тут же ляжет снова от синхронной волны. Лечится экспоненциальной задержкой со случайной добавкой.

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 1с, 2с, 4с плюс случайный разброс, чтобы попытки не совпали у всех сразу
const backoff = (attempt: number) => 2 ** attempt * 1000 + Math.random() * 300;

async function fetchWithRetry(url: string, init: RequestInit, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetch(url, {
        ...init,
        signal: AbortSignal.timeout(8000),
      });

      // 4xx кроме 429 — наша вина, повтор ничего не изменит
      if (res.status < 500 && res.status !== 429) return res;
      if (i === attempts - 1) return res;

      // если сервер сам сказал, когда возвращаться, — слушаем его
      const retryAfter = Number(res.headers.get("Retry-After")) * 1000;
      await sleep(retryAfter || backoff(i));
    } catch (err) {
      if (i === attempts - 1) throw err;
      await sleep(backoff(i));
    }
  }

  throw new Error("unreachable");
}

Заголовок Retry-After игнорируют почти все самописные интеграции, а зря: это единственный случай, когда чужой сервер прямо говорит, через сколько секунд его можно беспокоить. Спорить с ним — верный способ уехать в бан по IP.

Ошибка 4. Повторные заказы из-за отсутствия идемпотентности

У этой ошибки есть характерный симптом: в CRM появляются задвоенные заявки, а в бухгалтерии — задвоенные платежи.

Сценарий такой. Вы отправили запрос на создание заказа. Партнёр его принял, создал заказ, начал формировать ответ, и на этом моменте оборвалась сеть. Вы получили таймаут. С вашей стороны заказ не создан, с их стороны — создан. Логика ретрая честно отправляет запрос ещё раз, и заказов становится два.

Проблема в том, что вы физически не можете отличить «запрос не дошёл» от «ответ не вернулся». Единственное решение — ключ идемпотентности: уникальная строка, по которой сервер узнаёт повтор и возвращает результат первой операции вместо создания второй.

// Ключ строится из данных операции и переживает ретраи.
// Случайный uuid на каждую попытку смысла не имеет — он не совпадёт.
const idempotencyKey = `order:${orderId}:v1`;

await fetchWithRetry("https://pay.example.com/v1/payments", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAY_TOKEN}`,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payment),
});

Поддержку идемпотентности нужно проверять в документации до начала работ: заголовок называется по-разному, а некоторые API его не умеют вовсе. Если не умеет, защиту придётся строить у себя: хранить в базе идентификатор последней успешной операции и сверяться с ним перед отправкой.

Ошибка 5. Слепое доверие к структуре ответа

Код вида const { amount } = await res.json() живёт ровно до первого изменения на стороне партнёра.

API меняются без предупреждения чаще, чем принято думать: поле переезжает на уровень выше, число приходит строкой, вместо объекта возвращается массив, у необязательного поля появляется null. Статус при этом честные 200, а падение случается на три слоя ниже, в тот момент, когда undefined пытаются отформатировать как цену.

Разбирать такое в логах тяжело: стектрейс указывает на компонент, который ни в чём не виноват. Дешевле проверить структуру сразу на границе:

import { z } from "zod";

const LeadResponse = z.object({
  id: z.string(),
  status: z.enum(["new", "in_progress", "done"]),
  amount: z.number().optional(),
});

const parsed = LeadResponse.safeParse(await res.json());

if (!parsed.success) {
  // Ошибка всплывает там, где случилась, с понятной причиной
  logger.error("crm.lead.schema_mismatch", { issues: parsed.error.issues });
  return Response.json({ error: "bad_upstream_payload" }, { status: 502 });
}

const lead = parsed.data; // дальше по коду типы гарантированы

Схема заодно работает документацией. Через полгода она честнее покажет формат ответа, чем PDF от партнёра.

Ошибка 6. Вебхук обрабатывается синхронно

Вебхук устроен наоборот: чужой сервер стучится к вам и ждёт ответа. Если внутри обработчика вы записываете данные в базу, отправляете письмо и дёргаете ещё два сервиса, ответ уйдёт через несколько секунд.

Отправители такого не прощают. Типичный таймаут вебхука — 3–5 секунд, дальше доставка считается неудачной. Начинаются повторы, на каждый повтор вы снова отправляете письмо, клиент получает четыре одинаковых уведомления. При стабильно медленном ответе вебхуки могут отключить совсем.

Правило простое: проверить подпись, поставить задачу в очередь, немедленно ответить 200. Вся настоящая работа — за пределами HTTP-запроса.

import crypto from "node:crypto";

export async function POST(req: Request) {
  // Именно text(), а не json(): подпись считается по сырому телу,
  // а JSON.parse + JSON.stringify изменят пробелы и порядок ключей
  const raw = await req.text();
  const signature = req.headers.get("x-signature") ?? "";

  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex");

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!valid) return new Response("bad signature", { status: 401 });

  await queue.enqueue("payment.webhook", JSON.parse(raw));

  return new Response("ok", { status: 200 });
}

Проверка подписи здесь не формальность. Адрес вебхука открыт всему интернету, и без подписи любой желающий сможет отправить вам «оплата прошла». Сравнение через timingSafeEqual вместо обычного === защищает от атак по времени ответа: обычное сравнение строк завершается на первом несовпавшем символе и тем самым подсказывает, сколько символов угадано верно.

Второй нюанс: обработчик обязан переживать дубли. Отправители гарантируют доставку «хотя бы один раз», а не «ровно один раз», поэтому одно и то же событие может прийти дважды. Сохраняйте идентификатор события и пропускайте уже обработанные.

Обсудим ваш проект?

Оставьте контакты — перезвоним и обсудим задачу

Ошибка 7. Тишина вместо логов

Интеграция без логов ломается точно так же, как с логами. Разница в том, что вы узнаёте об этом от менеджера в понедельник, а не от алерта в пятницу вечером.

Чего обычно не хватает в логе, когда он всё-таки есть: идентификатора запроса, длительности, номера попытки и тела ответа при ошибке. Без них в разборе остаётся строчка Error: request failed, из которой ничего не следует.

logger.info("crm.lead.sent", {
  requestId, // сквозной id, чтобы связать цепочку
  endpoint: "/v2/leads",
  status: res.status,
  durationMs: Date.now() - startedAt, // видно деградацию до падения
  attempt, // рост числа попыток — ранний сигнал
});

Чего в логе быть не должно: токенов, полного тела запроса с персональными данными, номеров карт. Логи попадают во внешние сервисы, оседают в бэкапах и открыты половине команды. Для 152-ФЗ это отдельная головная боль, а для утечки — готовый подарок. Телефоны и почту маскируйте на уровне логгера, тело сохраняйте усечённым.

Минимум алертов, который стоит настроить сразу: доля ошибок выше 5% за пятнадцать минут, время ответа хуже секунды по 95-й перцентили, ноль успешных запросов за час там, где обычно поток. Последний ловит как раз тот случай из начала статьи: всё «работает», но заявки не доходят.

Чек-лист перед выкаткой

  • Ключи только на сервере, во фронтенд-бандле их нет, проверено поиском по исходникам
  • У каждого исходящего запроса выставлен таймаут, и он короче лимита платформы
  • Ретраи только на 5xx, 429 и сетевые ошибки, с экспоненциальной задержкой и разбросом
  • Заголовок Retry-After учитывается
  • Операции, создающие деньги или заказы, идут с ключом идемпотентности
  • Ответ проверяется по схеме, а не разбирается по памяти
  • Вебхуки проверяют подпись, отвечают быстро, переживают дубли
  • Логи содержат requestId, статус, длительность и номер попытки, но не содержат секретов
  • Настроены алерты на долю ошибок и на тишину
  • Ключи ротируются, и есть инструкция, что делать при утечке

Своими силами или к подрядчику

Разница проходит не по сложности кода, а по цене ошибки.

Своими силами разумно делать интеграции, где сбой означает неудобство: подтянуть курс валют, отправить уведомление в Telegram, забрать данные из открытого справочника. Не сработало — повторили руками.

Подрядчика имеет смысл звать там, где сбой означает деньги или обязательства: платежи, заказы, остатки на складе, обмен с 1С, передача персональных данных. Здесь нужны очередь, идемпотентность, мониторинг и человек, который будет разбирать инциденты. По нашему опыту двусторонний обмен с 1С или платёжным шлюзом занимает 2–4 недели вместе с тестовым контуром. Почти всё это время уходит не на код, а на согласование форматов и обработку краевых случаев.

Промежуточный вариант — сделать самим, а перед выкаткой заказать аудит. Пары дней обычно хватает, чтобы найти ключ в бандле, отсутствующий таймаут и вебхук без проверки подписи.

Заключение

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

Именно это отличает интеграцию, которая работает на демо, от интеграции, которая работает в пятницу вечером. Пройдите по чек-листу до выкатки: он короче, чем разбор последствий.

Обсудим ваш проект?

Оставьте контакты — перезвоним и обсудим задачу

Содержание
  • Что вообще значит «настроить API на сайте»
  • Ошибка 1. Ключ доступа лежит во фронтенде
  • Ошибка 2. Запрос без таймаута
  • Ошибка 3. Ретраи, которые добивают чужой сервис
  • Ошибка 4. Повторные заказы из-за отсутствия идемпотентности
  • Ошибка 5. Слепое доверие к структуре ответа
  • Ошибка 6. Вебхук обрабатывается синхронно
  • Ошибка 7. Тишина вместо логов
  • Чек-лист перед выкаткой
  • Своими силами или к подрядчику
  • Заключение
Поделиться:

Похожие статьи

Свои вебхуки: как отдавать события партнёрам и не потерять их
Автоматизация

Свои вебхуки: как отдавать события партнёрам и не потерять их

13 мин
OAuth 2.0 для интеграций: как подключить сайт к чужому сервису
Автоматизация

OAuth 2.0 для интеграций: как подключить сайт к чужому сервису

14 мин
5 новейших ИИ-моделей 2026: кто полезнее в реальной работе
Автоматизация

5 новейших ИИ-моделей 2026: кто полезнее в реальной работе

10 мин
Feature IT

Feature IT — платформа по обучению программированию и разработке цифровых продуктов. Мы создаём современные веб-решения для бизнеса и обучаем этому других!

Политика конфиденциальностиПользовательское соглашение

О компании

  • Блог
  • Карьера

Услуги разработки

  • Разработка сайтов под ключ
  • Веб-приложения на React/Next.js
  • Telegram-боты для бизнеса
  • Mini Apps (Telegram, VK)
  • SEO-оптимизированные сайты
  • Автоматизация бизнес-процессов
  • Поддержка и развитие IT-продуктов

Обучение

  • Курс Python с нуля
  • Алгоритмы и структуры данных
  • Паттерны проектирования
  • Подготовка к собеседованиям в IT
  • Практика на реальных проектах

Инструменты

  • Генератор UTM-меток
  • Счётчик символов