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

Честный контракт между системами экономит недели отладки

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

Отдельная строка в контракте - версия. Внешний сервис меняется по своему графику, и переход на новую версию не должен быть авралом. Адрес запроса фиксируется с номером версии, ответ разбирается по явной схеме, а неизвестные поля не ломают разбор. Такой подход позволяет переезжать на новое, когда удобно сайту, а не в ночь после письма о прекращении поддержки старого API.

Контракт проверяется тестами на сохранённых ответах: записи реальных ответов внешнего сервиса проигрываются локально, и конвейер ловит изменения формата до продакшена. Фикстуры обновляются при каждом осознанном переходе, и тесты продолжают работать без доступа к внешней сети, быстро и стабильно.

Техническая основа контракта - серверный прокси. Обращение к чужому API прямо из браузера раскрывает ключ и открывает дверь к злоупотреблению, поэтому все внешние вызовы проходят через собственный сервер:

app.get("/api/weather", async (req, res) => {
  const city = req.query.city;
  if (!allowed(city)) return res.status(400).json({ error: "недопустимый город" });
  const r = await fetch("https://api.example.com/v2/current?city=" + encodeURIComponent(city), {
    headers: { Authorization: "Bearer " + process.env.WEATHER_KEY },
    signal: AbortSignal.timeout(4000)
  });
  if (!r.ok) return res.status(502).json({ error: "внешний сервис недоступен" });
  const data = await r.json();
  res.json({ temp: data.main.temp, desc: data.weather[0].description });
});

Прокси скрывает ключ, нормализует ответ и оставляет за сайтом контроль над форматом: если внешний сервис завтра сменит поля, правится один файл, а не двадцать страниц.

Очереди развязывают скорость сайта и скорость внешнего сервиса

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

await db.jobs.insert({
  type: "export_report",
  payload: { userId: user.id, from, to },
  status: "queued"
});
res.json({ jobId, status: "queued" });

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

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

Интерфейс при этом обязан показывать правду о состоянии. Честный значок в обработке с возможностью уйти со страницы лучше вечной кнопки с крутилкой, которая обещает мгновение и длится минуту. Люди прощают ожидание, когда видят правду, и не прощают обман.

Повторные попытки спасают от временных сбоев только при правильной подготовке

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

async function callWithRetry(url, opts = {}, tries = 3) {
  for (let i = 1; i <= tries; i++) {
    try {
      const r = await fetch(url, { ...opts, signal: AbortSignal.timeout(4000) });
      if (r.status >= 500 && i < tries) throw new Error("server " + r.status);
      return r;
    } catch (e) {
      if (i === tries) throw e;
      await new Promise((ok) => setTimeout(ok, 300 * 2 ** (i - 1)));
    }
  }
}

Для операций с побочными эффектами, например списания или создания записей, каждая попытка несёт ключ идемпотентности, и повтор с тем же ключом не создаёт дубль на чужой стороне. Без этого ключа повторная попытка превращается в второе списание, и разбираться потом приходится с каждым клиентом вручную.

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

Кэш и ограничение частоты запросов защищают обе стороны

Большинство внешних API продают не бесконечность, а лимит: столько-то запросов в минуту и в день. Наивное использование упирается в потолок в самый неподходящий момент, поэтому расчёт ведётся заранее: сколько реальных обращений нужно сайту и что кладётся в кэш. Справочные данные, меняющиеся раз в час, запрашиваются раз в час, а не при каждом визите.

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

Со стороны сайта полезно собственное ограничение частоты на прокси-эндпоинты: один визит не должен порождать десятки внешних вызовов, а один клиент не должен выжать дневной лимит своим скриптом.

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

Ошибки внешних сервисов требуют продуманного понижения

Что делает страница, когда внешний сервис лёг? Худший вариант - белая простыня с непонятной ошибкой, лучший - страница без внешнего блока, но со всем остальным. Зависимость проектируется с режимом понижения: блок погоды заменяется последним кэшем, блок комментариев прячется с вежливой подписью, кнопка оплаты предлагает резервный способ.

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

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

Очереди для таких случаев имеют отдельное кладбище: список задач, которые не прошли после всех попыток. Команда разбирает его раз в день, и каждая запись становится либо исправлением, либо осознанным отказом с уведомлением клиенту. Без этого списка мёртвые задачи исчезают бесследно, и человек остаётся со статусом в обработке навсегда.

Вебхуки и очередь событий заменяют постоянный опрос

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

Приём вебхука - точка входа, а не конец работы:

app.post("/hooks/payments", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-signature"];
  if (!verifySignature(req.body, sig)) return res.status(401).end();
  res.status(202).end();
  queue.add("payment_event", JSON.parse(req.body));
});

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

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

Наблюдаемость превращает сбои интеграции в понятные ситуации

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

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

  1. контракт описан, обязательные поля и форматы ошибок перечислены;
  2. ключи не покидают сервер и не попадают в браузер;
  3. у вызовов есть таймаут и ограниченное число повторных попыток;
  4. операции с побочными эффектами несут ключ идемпотентности;
  5. ответы кэшируются там, где данные меняются редко;
  6. отказ внешнего сервиса не рушит страницу, а включает режим понижения;
  7. у интеграции есть метрики и владелец.

Такой список занимает один экран и закрывает большинство сюрпризов до релиза.

Каждый инцидент с интеграцией заканчивается короткой записью: что случилось, сколько длилось, что исправлено. Через полгода такие записи подсказывают, где добавить страховку, а где внешний сервис просто нестабилен и заслуживает режима понижения по умолчанию. Бесшовная интеграция в итоге складывается не из библиотеки-соединителя, а из привычек: контракт, очередь, повтор, кэш, понижение и наблюдение. Внешние сервисы будут сбоить, меняться и исчезать, и сайт, построенный на этих привычках, переживает это без единого заметного посетителю шва.