Настройка API на сайте: 7 ошибок, которые вылезают только в проде
Как настроить API на сайте: где хранить ключи, таймауты, ретраи, идемпотентность и вебхуки. Разбор ошибок, которые на тесте не видно, и чек-лист перед выкаткой.
Редакция Feature·
22 авг
·
13 мин
·
Пятница, 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, исчерпанная квота или счёт за платный сервис.
Правильный вариант — прокси через собственный маршрут. Ключ остаётся на сервере, браузер о нём не знает:
Бонусом прокси решает вопрос с 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 к сайту?
Спроектируем и настроим интеграцию, которая переживёт продакшен
Логика «не получилось — повторим» кажется очевидной. В реальности наивный повтор превращает мелкий сбой у партнёра в полноценную аварию.
Два правила, которые нарушают чаще всего.
Не повторять то, что не изменится. Ответ 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 на сайте выглядит как задача на день, потому что первый успешный запрос действительно делается за час. Остальные девяносто процентов работы — про поведение системы, когда что-то идёт не так: партнёр отвечает медленно, сеть рвётся посреди платежа, формат ответа меняется без предупреждения.
Именно это отличает интеграцию, которая работает на демо, от интеграции, которая работает в пятницу вечером. Пройдите по чек-листу до выкатки: он короче, чем разбор последствий.