MAATRIX / Блог / Госсервис отвечает медленно: интеграция, которая переживёт таймауты

Госсервис отвечает медленно: интеграция, которая переживёт таймауты

MAATRIX

Пользователь нажал «Отправить», крутилка провисела двадцать секунд, и вместо результата — «Ошибка сервера, попробуйте позже». А по факту заявка на той стороне обработалась через минуту: просто запрос успел отвалиться по таймауту, и синхронный обработчик вернул клиенту 500-ю. Это не баг кода в привычном смысле, а архитектурная ошибка — интеграция спроектирована так, будто внешний сервис всегда быстрый и доступен. Госсистемы часто под нагрузкой отвечают медленно или временно недоступны — это данность, с которой приходится проектировать, а не аномалия, которую нужно дождаться и «починить у них». Разберём, как построить интеграцию, которая переживает медленный или нестабильный ответ внешней стороны, не подставляя пользователя и не теряя данные.

Почему синхронная модель ломается первой

Самый частый способ подключить внешний API — сделать это в том же request-response цикле, что обслуживает пользователя: пришёл HTTP-запрос от клиента, обработчик внутри него дёргает внешний сервис, ждёт ответ, формирует ответ клиенту. Это нормально работает, пока внешняя сторона отвечает за 200–500 мс. Проблема начинается, когда сервис начинает отвечать за 5, 15, 40 секунд, а иногда не отвечает вовсе.

В синхронной модели у этого сразу несколько последствий:

  • Воркер приложения занят всё это время. Если у вас пул из N php-fpm/gunicorn/node-воркеров, а внешний сервис завис на 30 секунд под своей нагрузкой, каждый попавший на интеграцию запрос съедает воркер на 30 секунд. Пул исчерпывается за минуты, и падать начинает ваш сайт — по причине, никак не связанной с госсервисом.
  • Клиент получает жёсткую ошибку там, где ничего критического не произошло. Запрос мог дойти и даже обработаться — ответ просто не успел вернуться до таймаута. Пользователь видит «ошибка», хотя заявка на деле в очереди.
  • Ретрай в лоб на клиенте создаёт дубликаты. Пользователь, увидев ошибку, жмёт кнопку ещё раз. Если первый запрос всё же дошёл, операция отправлена дважды — и если она не идемпотентна, в худшем случае это двойное списание.

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

Таймауты: не слишком короткие, не бесконечные

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

Частая ошибка — таймаут по умолчанию в HTTP-клиенте (фактически бесконечность) или наоборот, агрессивный таймаут в 3–5 секунд «чтобы не тормозило». Оба варианта плохие: бесконечный таймаут держит соединение и воркер открытыми до талого — если сервис завис, а не просто медленный, вы узнаете об этом лишь когда сработает таймаут ОС (может быть 15+ минут) или кончится память под сокеты. Слишком короткий таймаут обрывает запросы, которые успешно завершились бы за 8–10 секунд под нагрузкой госсервиса — вы получаете лавину ложных ошибок и ретраев ровно тогда, когда внешняя сторона и так перегружена, усугубляя нагрузку на неё же.

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

# Пример на Python + httpx: раздельные таймауты на этапы соединения
import httpx

timeout = httpx.Timeout(
    connect=3.0,   # установка TCP/TLS-соединения — должна быть быстрой всегда
    read=15.0,     # ожидание тела ответа — сюда закладываем реальную медлительность сервиса
    write=5.0,     # отправка тела запроса
    pool=2.0,      # ожидание свободного соединения в пуле
)

client = httpx.Client(timeout=timeout)

Ключевая идея: connect-таймаут должен быть коротким (сервис либо доступен по сети, либо нет — трёх секунд достаточно), а read-таймаут — откалиброван по реальным замерам, а не выдуман. Соберите статистику по фактическому времени ответа за пару недель (гистограмму p50/p95/p99) и ставьте read-таймаут на уровне p99 плюс запас, а не на p50. Если p99 у сервиса — 12 секунд, таймаут в 5 секунд будет резать примерно каждый сотый запрос без всякой реальной проблемы на его стороне.

Отдельно нужен общий бюджет времени на всю операцию, включая ретраи — иначе цепочка повторов с задержками растягивается на минуты (об этом — в разделе про статус). Для операций с интерактивным ответом разумный бюджет — от 30 секунд до нескольких минут; для заведомо асинхронных операций («документ ушёл в обработку») бюджет может измеряться часами — такую операцию нельзя держать в синхронном HTTP-запросе ни в каком виде.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Очередь вместо ожидания в потоке запроса

Если внешний сервис может отвечать долго, естественное решение — вынести сам факт ожидания за пределы HTTP-обработчика пользовательского запроса. Общая схема:

  1. Бэкенд валидирует данные, сразу сохраняет задачу в таблицу заданий и сразу возвращает пользователю ответ «принято, обрабатывается» с идентификатором заявки.
  2. Отдельный воркер, не связанный с HTTP-запросом пользователя, забирает задачу из очереди и обращается к госсервису — со своим таймаутом и ретраями.
  3. Результат записывается обратно в задачу/базу.
  4. Клиент узнаёт о результате — опросом (polling) по идентификатору, через WebSocket, или уведомлением (email, push, вебхук), если такой канал есть.

Технически это можно поднять на чём угодно — от простой таблицы jobs в PostgreSQL с полем status и воркера, который опрашивает её через SELECT ... FOR UPDATE SKIP LOCKED, до полноценного брокера сообщений вроде RabbitMQ или очереди на Redis (Celery, RQ, BullMQ). Для большинства интеграций с одним внешним сервисом и умеренным потоком заявок хватает связки «таблица заданий в Postgres + один-два воркера»: меньше движущихся частей, чем отдельный брокер, а надёжности достаточно, если нагрузка не десятки тысяч заявок в секунду. Брокер оправдан, когда очередей несколько, нужны приоритеты задач или объём заявок реально большой.

Пример минимальной схемы задания в Postgres:

CREATE TABLE gov_integration_jobs (
    id            bigserial PRIMARY KEY,
    idempotency_key text NOT NULL UNIQUE,
    payload       jsonb NOT NULL,
    status        text NOT NULL DEFAULT 'pending', -- pending, processing, done, failed
    attempts      int  NOT NULL DEFAULT 0,
    max_attempts  int  NOT NULL DEFAULT 5,
    next_attempt_at timestamptz NOT NULL DEFAULT now(),
    last_error    text,
    result        jsonb,
    created_at    timestamptz NOT NULL DEFAULT now(),
    updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ON gov_integration_jobs (status, next_attempt_at)
    WHERE status IN ('pending', 'processing');

Воркер забирает задания с status = 'pending' и next_attempt_at <= now(), помечает processing, делает попытку и переводит в done/failed либо откладывает следующую попытку, увеличив attempts. Побочный эффект схемы: она сама снимает нагрузку с веб-воркеров — HTTP-обработчик делает быструю запись в базу вместо долгого сетевого вызова, и сайт не ложится, даже если госсервис еле дышит.

Экспоненциальный backoff и разумный лимит попыток

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

Стандартный паттерн — экспоненциальный backoff с джиттером (случайным разбросом), чтобы ретраи от разных заданий не били по сервису синхронными волнами:

import random

def next_delay(attempt: int, base: float = 2.0, cap: float = 300.0) -> float:
    """attempt начинается с 1. Возвращает задержку в секундах перед следующей попыткой."""
    raw = min(cap, base * (2 ** (attempt - 1)))
    jitter = raw * random.uniform(0.5, 1.0)
    return jitter

# attempt=1 -> ~1-2s, attempt=2 -> ~2-4s, attempt=3 -> ~4-8s,
# attempt=4 -> ~8-16s, ... до потолка в cap секунд

Несколько практических моментов:

  • Лимит попыток должен быть конечным. Пять-семь попыток с растущей задержкой обычно достаточно, чтобы пережить кратковременную деградацию, но не превратить зависшую задачу в вечный фоновый процесс. После исчерпания лимита задание помечается failed — дальше вопрос эскалации, а не бесконечных автоматических попыток.
  • Различайте типы ошибок. Таймаут, 502/503/504, обрыв соединения — повод для ретрая. Ошибка валидации (400, «неверный формат ИНН») или отказ по существу (403) — ретраить бессмысленно, сразу переводите в failed с осмысленной причиной для пользователя.
  • Отличайте «сервис ответил с ошибкой» от «не ответил вовсе». Логировать их стоит по-разному, иначе потом не разобрать в метриках, «у них было недоступно» или «мы отправляем что-то не то».

Если задания критичны (платежи, юридически значимые подачи), после исчерпания ретраев разумно ставить алерт дежурному, а не тихо помечать failed и забывать.

Идемпотентность: без неё ретраи опаснее, чем сам сбой

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

Базовый механизм — ключ идемпотентности (idempotency key), который генерируется один раз на стороне инициатора (как idempotency_key в примере таблицы выше) и передаётся с запросом на всех попытках, включая повторные. Если внешний сервис поддерживает такой ключ — используйте её: сервис сам распознает повтор. Если поддержки нет (для многих гос-API это по-прежнему так), обеспечивать идемпотентность приходится самим:

  • Перед отправкой проверяйте, не было ли уже успешно обработанного задания с этим ключом — если было, возвращайте сохранённый результат вместо повторной отправки.
  • Если сомневаетесь, дошёл ли предыдущий запрос (например, таймаут случился после отправки, но до ответа), по возможности сначала делайте безопасный статусный запрос вместо повторной отправки самой операции — если у сервиса есть эндпоинт проверки статуса по вашему референсу.
  • Уникальный индекс в базе (UNIQUE на idempotency_key) — простая защита от гонки, если два воркера одновременно возьмутся за одно и то же задание.

Подробнее — в отдельной статье про идемпотентность и почему повтор запроса может списать деньги дважды: идемпотентность — свойство операции («что происходит при повторе»), а backoff — стратегия доставки («когда повторять»). Одно без другого не работает.

Информирование пользователя: статус вместо мгновенной ошибки

Как только ожидание вынесено из HTTP-запроса, встаёт вопрос UX: что пользователь видит следующие секунды, минуты, а иногда и часы, пока задание крутится в очереди? Правильный ответ — не «ошибка», а честный статус процесса:

  • Мгновенный ответ на исходный запрос — подтверждение приёма, а не результат. «Заявка №12345 принята, обрабатывается» с оценкой по времени, если она есть («обычно занимает 1–5 минут», на основе реальной статистики, а не догадки).
  • Страница/экран статуса заявки, куда пользователь может вернуться и увидеть состояние: в очередиотправлено во внешний сервисготово / ошибка, требуется действие. Простой polling раз в 5–10 секунд на /api/jobs/{id}/status закрывает большинство сценариев без WebSocket.
  • Уведомление по завершении, если есть канал связи — email, push, телеграм-бот. Особенно важно для операций, которые занимают часы: не заставляйте пользователя держать вкладку открытой.
  • Осмысленное сообщение при финальном отказе, а не просто «ошибка». Объясните, что делать дальше: повторить позже, обратиться в поддержку, проверить данные. Пользователь, который видит только «Error 500», обычно жмёт кнопку ещё раз, порождая новую заявку поверх старой.

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

Наблюдаемость: отличить сбой у вас от сбоя у них

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

Минимальный набор метрик, которые стоит собирать по такой интеграции:

МетрикаНа что смотреть
Время ответа сервиса (p50/p95/p99)Рост p95/p99 — ранний признак деградации до того, как начнутся таймауты
Доля запросов, ушедших в ретрайРезкий рост — сигнал о проблемах на стороне сервиса
Глубина очереди (pending + processing)Растущий тренд без выполаживания — воркеров не хватает или сервис лёг всерьёз
Доля заданий, ушедших в failed после всех попытокДаже 1–2% на объёме требует ручного разбора
Возраст самого старого pending-заданияРастёт без остановки — вероятен зависший воркер, а не проблема сервиса

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

Алерты стоит вешать не на единичный таймаут (это штатная ситуация, для которой и придуман ретрай), а на устойчивые тренды: глубина очереди растёт третий час подряд, доля failed превысила порог, самое старое pending-задание старше разумного предела. Это отличает временную медлительность, которую система переживает сама, от реальной проблемы, требующей вмешательства человека.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Какой таймаут ставить, если статистики по сервису ещё нет?

Начните консервативно: connect — 3–5 секунд, read — 20–30 секунд, с калибровкой по p95/p99 после накопления пары недель реальных данных.

Обязательно ли поднимать отдельный брокер сообщений (RabbitMQ, Kafka)?

Нет, если поток заявок умеренный — таблица заданий в PostgreSQL с SELECT ... FOR UPDATE SKIP LOCKED и один-два воркера обычно достаточны и проще в эксплуатации. Брокер оправдан при высоком объёме или нескольких независимых очередях.

Что делать, если у сервиса нет ни идемпотентности, ни статусного эндпоинта?

Худший, но реальный случай: дедупликация по бизнес-ключу перед отправкой, консервативные таймауты (чтобы минимизировать «неясно, дошло ли») и ручная сверка для пограничных случаев.

Как понять, что медленно не сеть, а перегружен сам сервис?

Сравните время connect и read отдельно — если connect стабильно быстрый, а read растёт, проблема в обработке запроса сервисом, а не в сети до него.

Резервировать отдельный сервер под воркеры очереди или держать их на одном с веб-приложением?

На старте можно на одном, пока воркер не конкурирует за CPU/память всерьёз. Когда очередь начинает расти вместе с трафиком сайта, воркеры стоит вынести на отдельную машину.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →