# Polling endpoint статуса: как агенты ждут без циклов

Агент, который запускает внешнее задание, не должен бесконечно спрашивать: «Ну что, уже готово?», пока провайдер, бюджет или человек не сдадутся. Для polling endpoint статуса нужен явный контракт: что считается прогрессом, когда можно отправить следующий запрос, когда ожидание заканчивается и кто решает, что делать дальше.

Я видел, как безобидные на первый взгляд проверки статуса превращались в сотни вызовов: идентификатор задания был действительным, endpoint продолжал возвращать HTTP 200, а агенту никто не сказал, что после дедлайна ответ «выполняется» больше нельзя считать приемлемым. Решение не в хитром расписании. Нужно превратить ожидание в ограниченное действие с подтверждениями и путём эскалации.

## Состояние «выполняется» разрешает ждать, но не действовать

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

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

```json
{
  "job_id": "exp_71c",
  "state": "running",
  "updated_at": "2025-04-18T10:24:00Z"
}
```

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

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

- **Ожидающие состояния** разрешают позже отправить запрос статуса, например `queued`, `running` или `processing`.
- **Успешные состояния** разрешают конкретное указанное следующее действие, например получение URL результата.
- **Состояния ошибки** останавливают запуск и сохраняют ошибку провайдера.
- **Состояния отмены и истечения срока** останавливают запуск без повторной отправки, если человек явно не попросил об этом.
- **Неизвестные состояния** останавливают запуск, потому что интеграция не может безопасно предположить, что `paused`, `awaiting_review` или новое значение безвредны.

Проверяйте ответ статуса и на противоречия. Задание со статусом `succeeded`, но без обязательной ссылки на результат, ещё не готово к использованию. Задание со статусом `running` после указанного им времени истечения требует эскалации, а не большей веры в polling.

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

## Фиксированные интервалы создают синхронную нагрузку

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

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

Практическое расписание может выглядеть так:

```text
base_delay = 5 seconds
max_delay = 120 seconds
attempt = number of completed polls
raw_delay = min(max_delay, base_delay * 2^attempt)
actual_delay = random value between 50% and 100% of raw_delay
```

Случайный диапазон важен. Детерминированная последовательность 5, 10, 20, 40 и 80 секунд лишь переносит синхронизацию на более широкие волны. В некоторых системах работает и полный jitter, когда случайное значение может находиться в диапазоне от нуля до предела. Для polling заданий я предпочитаю нижнюю границу: если запуск снова и снова выбирает почти нулевые задержки, это начинает напоминать шторм повторных запросов.

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

Если API возвращает поле `next_check_at`, `poll_after_seconds` или похожее значение, считайте его рекомендацией провайдера. Но сначала проверьте его. Отклоняйте отрицательные значения, чрезмерно долгие интервалы, превышающие дедлайн операции, и даты, которые невозможно разобрать. Агент может ждать до указанного времени или до собственного дедлайна, в зависимости от того, что наступит раньше.

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

## Дедлайн и бюджет запросов ловят разные сбои

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

Дедлайн определяет, как долго рабочий процесс может оставаться нерешённым. Он не даёт агенту сохранять устаревшее задание до выходных только потому, что API всё ещё говорит `queued`. Выбирайте его с учётом последствий задержки для бизнеса, документированного срока хранения задания у провайдера и момента, после которого решение должен принять человек. Не выводите его только из среднего времени выполнения. Средние значения скрывают зависшие задания.

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

У бюджета ошибок более узкая задача. Считайте отказы в соединении, ошибки DNS, сбои TLS и ответы 5xx, которые не позволяют агенту узнать состояние задания. Один тайм-аут не доказывает, что задание завершилось ошибкой. Повторение одного и того же неудачного запроса в течение часа не доказывает терпение.

Используйте похожую запись операции и сохраните её надёжно до первого polling-запроса:

```json
{
  "operation_id": "report-export-2025-04-18-01",
  "provider_job_id": "exp_71c",
  "started_at": "2025-04-18T10:20:00Z",
  "deadline_at": "2025-04-18T11:00:00Z",
  "max_status_requests": 12,
  "max_transport_failures": 3,
  "status_requests_used": 0,
  "transport_failures_used": 0,
  "last_known_state": "queued"
}
```

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

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

## Условия остановки должны выполняться, а не просто существовать на бумаге

Фраза «остановиться, если это займёт слишком много времени» подходит человеку, но не является условием, которое агент может выполнить. Определяйте условия остановки как предикаты над текущей записью и ответом.

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

Останавливайтесь с ошибкой, когда провайдер сообщает конечную ошибку, ответ невозможно разобрать или состояние не входит в список разрешённых состояний интеграции. Неизвестные состояния требуют такой же серьёзности, как отказ в авторизации. Провайдер может без предупреждения добавить состояние `needs_payment`, `manual_review` или `blocked`. Если решить, что оно означает «подождать», изменение у провайдера превратится в бесконечный цикл у вас.

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

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

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

Именно здесь команды совершают дорогую ошибку: считают GET безвредным по определению. HTTP определяет GET как безопасный метод в семантическом смысле: клиент не запрашивал изменение состояния. Это обязательство сервера, а не магическое свойство URL. Проверьте поведение провайдера в тестовом аккаунте и его документации, прежде чем считать вызов наблюдением.

## Учитывайте сигналы протокола, которые уже отправляют провайдеры

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

RFC 9110 определяет `Retry-After` как указание минимального времени, которое клиент должен подождать перед следующим запросом. Поле может содержать задержку в секундах или дату HTTP. Разбирайте оба варианта. Если запрос статуса получает 503 с `Retry-After: 120`, не применяйте обычную тридцатисекундную задержку и не пробуйте снова раньше времени. Подождите как минимум две минуты, если это позволяет дедлайн операции.

RFC 6585 определяет HTTP 429, Too Many Requests, и говорит, что ответ может содержать `Retry-After`. Провайдеры могут не передавать этот заголовок, поэтому агенту всё равно нужен собственный backoff. Ответ 429 без указаний должен резко увеличить задержку и расходовать определённый вами бюджет ошибок или ограничений частоты. Если запуск продолжает получать 429, нужна эскалация. Нельзя бесконечно растягивать таймер.

Для 202 Accepted проверьте тело и заголовки ответа на наличие location, идентификатора задания и указанного ресурса статуса. Не составляйте предполагаемый URL из endpoint отправки. Одни провайдеры используют location результата, другие location статуса, а некоторые позже возвращают конечное представление. Следуйте документированному контракту.

Для 404 не всегда предполагайте, что задания никогда не существовало. Недавно отправленное задание может быть недоступно из-за eventual consistency, а завершённое может исчезнуть после окончания срока хранения. Какое объяснение правдоподобно, определяет исходный контракт провайдера. Если документированное поведение этого не объясняет, классифицируйте ситуацию как ошибку интеграции и эскалируйте её, указав идентификатор задания и временные отметки.

Для 401 или 403 остановите polling. Повторение ошибок авторизации с теми же учётными данными создаёт шум и может включить защиту провайдера. Человеку нужно проверить доступ, ротацию учётных данных, область действия аккаунта или настройки шлюза. Polling завершён.

Для ответов 5xx и сетевых ошибок используйте бюджет транспортных ошибок. Сохраняйте код ответа, время запроса и идентификатор запроса провайдера, если он есть. Эти сведения нужны службе поддержки или оператору, чтобы отличить потерянный ответ от ошибки самого задания.

## Эскалация должна запрашивать решение, а не выгружать журнал

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

Сделайте причину эскалации машиночитаемой. Используйте категории вроде `deadline_exceeded`, `request_budget_exhausted`, `rate_limited`, `unknown_state`, `authorization_denied` или `provider_failure`. Каждая категория должна определять допустимое следующее действие. Увидев `unknown_state`, человек может разрешить временную паузу, пока кто-то проверяет изменения у провайдера. Увидев `authorization_denied`, он не должен одобрять ещё один такой же запрос.

В сообщении об эскалации должно быть достаточно контекста для решения, но не должно быть секретов:

```text
External job needs a decision
Operation: report-export-2025-04-18-01
Provider job: exp_71c
Last state: running
Elapsed time: 40 minutes
Status requests: 12 of 12
Last HTTP result: 200 at 10:58 UTC
Stopped because: request_budget_exhausted
Safe options: extend waiting once, cancel at provider, inspect provider console
```

Не формулируйте выбор как «продолжить?». Это подталкивает оператора одобрить бесконечный цикл. Предлагайте ограниченные варианты. «Продлить на пятнадцать минут и сделать ещё четыре проверки» указывает стоимость и создаёт новый предел. «Отменить у провайдера» следует показывать только при наличии документированного действия отмены и понимании его последствий со стороны оператора.

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

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

## Контроллер polling нуждается в постоянном состоянии и одном владельце

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

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

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

Этот псевдокод показывает порядок, который предотвращает большинство случайных циклов:

```text
load operation
if operation is terminal or escalated:
    exit
if current_time >= operation.deadline_at:
    record timeout and escalate
    exit
if operation.status_requests_used >= operation.max_status_requests:
    record budget exhaustion and escalate
    exit
if current_time < operation.next_poll_at:
    schedule wakeup and exit

acquire ownership lease
reload operation
increment status_requests_used and persist
send one status request
persist response metadata

if response has terminal success and required result fields are valid:
    record success
else if response has terminal failure or unknown state:
    record stop reason and escalate
else if response requires waiting:
    calculate next_poll_at with provider guidance, backoff, and jitter
    persist next_poll_at
else:
    record integration failure and escalate
```

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

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

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

## Подтверждение человека нужно на границе эскалации

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

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

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

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

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

## Аудитируйте решение ждать, а не только сам запрос

Журнал запросов сообщает, что агент вызвал `/jobs/exp_71c`. Он не сообщает, был ли вызов разрешён планом polling, проигнорировал ли агент `Retry-After` и не превысило ли задание дедлайн.

Для каждого вызова записывайте решение контроллера: текущее состояние, запланированное время, источник задержки, число запросов, дедлайн, код ответа, разобранное состояние задания и принятое решение. Источник задержки может быть `provider_retry_after`, `provider_poll_hint`, `local_backoff` или `manual_extension`. Это небольшое поле сильно упрощает разбор инцидента.

Полезная последовательность аудита читается как история:

```text
10:20:00 submitted job exp_71c, deadline 11:00:00, budget 12
10:20:05 polled, state queued, next poll 10:20:14 from local backoff
10:20:14 polled, state running, next poll 10:20:31 from local backoff
10:20:31 received 503, Retry-After 120, next poll 10:22:31 from provider guidance
10:22:31 polled, state running, next poll 10:23:48 from local backoff
10:58:00 polled, state running, request budget exhausted, escalated
```

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

Sallyport сохраняет сессии агентов и отдельные вызовы в зашифрованном журнале аудита с цепочкой хешей, а `sp audit verify` проверяет эту цепочку офлайн поверх шифротекста. Такие свидетельства особенно полезны, когда собственная запись операции объясняет, почему произошло каждое наблюдаемое обращение.

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