Асинхронная операция API
Большой экспорт каталога может выполняться дольше обычного обмена. Держать соединение открытым до завершения необязательно, но ранний ответ должен честно объяснять, что уже принято и чего ещё нет. Статус «запрос принят» нельзя показывать пользователю как «файл успешно создан».
В этом уроке спроектируем фоновое задание. Результат — вы сможете разделить принятие, чтение состояния, успех работы и ошибку работы. Реальных очереди и worker нет: сообщения и переходы состояния являются моделью. Каждый самостоятельный сценарий начинается с четырёх курсов снимка s1.
Команда создаёт задание
Вводим защищённый POST /api/exports с телом, которое выбирает доступный снимок каталога. Alice u17 станет владельцем задания. Bearer используется вместо cookie; браузерная cookie-версия также потребовала бы Origin и X-CSRF-Token. Прикладной ключ обязателен для повторяемого принятия.
POST /api/exports HTTP/1.1
Host: catalog.example
Authorization: Bearer tok-alice
Content-Type: application/json
Accept: application/json, application/problem+json
Idempotency-Key: export-s1-a001
{"snapshot":"s1"}
Сначала проверяются вход, credentials, право на операцию, частотный бюджет и доступность снимка. Если s1 уже истёк до принятия, выбранный договор возвращает 410, а не создаёт задание с непонятным будущим источником. Работник не должен позднее читать изменяющийся живой список под видом обещанного снимка.
После принятия данные источника закрепляются для задания отдельно от десятиминутного срока курсора списка. Истечение курсора не уничтожает уже принятую работу. Такая граница требует реального механизма удержания снимка или копии данных; здесь мы только описываем необходимость, не выполняем копирование базы.
202 не утверждает завершение
Предполагаемый ответ:
HTTP/1.1 202 Accepted
Location: /api/exports/j001
Retry-After: 2
Content-Type: application/json
Cache-Control: private, no-store
{"id":"j001","state":"queued","resultUrl":null}
202 сообщает принятие к обработке, которая ещё не завершена. Location указывает отдельный ресурс состояния. Retry-After в нашем приложении подсказывает интервал следующего опроса; это не универсальная гарантия запуска задания через две секунды. Клиенту не нужно повторять POST по Location. Смысл 202 в RFC 9110.
Строка queued означает, что задание принято в долговечную модель очереди и ожидает обработки. Реальная система обязана согласовать запись задания и способ его обнаружения worker. Просто записать 202 в сокет, а затем попытаться сохранить задачу было бы недостаточно для нашего обещания принятия.
Мы не утверждаем, что worker уже работает или что экспорт непременно будет успешным. Нехватка места, повреждённый источник или ошибка генерации могут завершить фоновой результат неудачей. Они должны получить наблюдаемое состояние, а не оставить queued навсегда без объяснения.
Переходы состояния ограничены
В модели предусмотрены четыре значения:
queued -> running -> succeeded
-> failed
queued переходит в running, когда обработчик берёт работу. Из running она завершается успехом или неудачей. После терминального состояния результат не переписывается как будто задача снова начала другую работу. Повторная новая экспортная операция получает своё намерение и идентификатор.
Отмена в нашем контракте не поддерживается. Поэтому нельзя угадать DELETE /api/exports/j001 и считать, что он гарантированно остановит worker. Добавление отмены потребовало бы определить гонку с завершением, очистку результата, доступные состояния и повтор команды. Лучше сохранить узкий честный договор, чем перечислить неподтверждённые действия.
Внутренние повторы worker могут существовать, однако они не должны создавать несколько наблюдаемых финальных файлов одной задачи. Для реальной реализации нужны согласование владения обработкой, восстановление после сбоя и атомарная публикация результата. Стрелки на диаграмме являются требованием, а не реализацией этих механизмов.
Чтение состояния остаётся отдельным GET
Alice обращается по сохранённому Location:
GET /api/exports/j001 HTTP/1.1
Host: catalog.example
Authorization: Bearer tok-alice
Accept: application/json
Пока работа выполняется, ожидается 200 с state: running и resultUrl: null. Это успешное чтение доступного ресурса состояния. HTTP-статус не обязан стать 202 только потому, что фоновой процесс ещё занят: 202 относился к принятию исходной команды.
Проверка владельца выполняется каждый раз. Пользователь, не владеющий закрытым j001, получает выбранный 404. Ротация сессии Alice не меняет subject u17 и не лишает её собственного задания, пока права остаются действующими. Отзыв доступа и прекращение фоновой работы — разные решения, которые реализации следует согласовать явно.
Ответ персонален и использует private, no-store. Передача job id в публичный кеш или страницу чужого пользователя нарушала бы границу доступа. Не следует помещать Bearer в query URL для удобной ссылки на состояние.
Успех даёт адрес результата
После завершения ожидаемое представление:
{"id":"j001","state":"succeeded","resultUrl":"/api/files/f002"}
Файл f002 содержит JSON экспорта, а не двенадцатибайтовый текст f001 из урока Range. Он также принадлежит u17 и выдаётся отдельным защищённым GET. Наличие resultUrl не отменяет проверки owner и не означает вечной доступности.
Клиент меняет сообщение интерфейса на «экспорт готов» только после succeeded. До этого он показывает ожидание или выполнение. Полезно хранить id и адрес состояния, чтобы обновление страницы не превращалось в повтор запуска с новым ключом.
Запись и файл хранятся двадцать четыре часа после завершения. После истечения для известного владельцу ресурса выбираем 410 с пригодной причиной; это отличается от скрывающего чужой ресурс 404. Реализация должна определить, сколько живёт информация об истечении, иначе после удаления всех следов она уже не сможет честно отличить старый id от несуществующего.
Неудача работы не является ошибкой чтения
Допустимое представление провалившегося задания:
{"id":"j001","state":"failed","resultUrl":null,"error":{"type":"https://catalog.example/problems/export-failed","title":"Экспорт не завершён","status":500,"detail":"Не удалось подготовить результат."}}
GET этого имеющегося доступного состояния по-прежнему возвращает HTTP 200. Вложенный problem описывает неудачу фонового выполнения; его status является контекстом этой ошибки и не заменяет статус успешно прочитанного состояния. Клиент сначала анализирует state, а затем показывает безопасное объяснение error.
Ответ не должен раскрывать пути серверного диска, credentials или внутреннюю трассировку. Подробная диагностика принадлежит журналу реализации. Отдельно возможен настоящий 503 при невозможности прочитать хранилище заданий; тогда текущий GET действительно не выполнился, и получатель не делает вывод, что экспорт уже failed.
Нельзя объявлять успех по одному существованию файла
Работник мог записать временные байты f002, но ещё не опубликовать согласованное состояние задания. Клиент ориентируется на succeeded и доступный готовый resultUrl, а не угадывает файл по последовательности id. Публикация результата должна оставлять возможность восстановиться после сбоя между хранением и финальным статусом.
Если итоговый файл отсутствует при succeeded, обещание нарушено даже при правильном JSON состояния. Если файл есть, а состояние осталось running, реализации нужен механизм восстановления, иначе человек будет ждать бесконечно. Здесь мы задаём обе границы, не утверждая, что одна запись поля state уже решает их.
Повтор принятия не создаёт второе задание
Реестр Idempotency-Key связывает subject u17, POST, путь /api/exports, ключ и нормализованный выбор снимка. Его двадцатичетырёхчасовой срок отсчитывается по прежнему договору принятой операции, а срок результата задания — после завершения. Это два разных времени.
При потере 202 тот же ключ и тело должны вернуть прежнее принятие с j001, не запустить j002. Сохранённый ответ принятия может по-прежнему говорить queued; свежий статус читается отдельным GET. Иные данные под тем же ключом дают 409. Атомарная фиксация реестра и задания нужна независимо от того, успел ли клиент увидеть ответ.
Опрос получает собственный бюджет чтения и учитывает подсказанный интервал; он не расходует ведро защищённых изменений. Здесь опрос не выполнялся, а параметры реального GET-ограничителя не измерены. Мы получили наблюдаемую модель долгой операции. Следующий урок использует уведомление вместо постоянного ожидания, но также учитывает потерю ответа, повтор и неизвестный порядок событий.