Содержание

Асинхронные задачи и опрос состояния в REST API

Что должен вернуть API, если файл уже принят, а готовое изображение появится только через несколько секунд? Сервису ещё предстоит проверить исходник, создать два размера, сохранить результаты в объектном хранилище и обновить базу, но держать HTTP-запрос открытым всё это время необязательно.

Вместо готового изображения API возвращает обещание: «запрос принят, вот идентификатор операции и адрес, по которому можно узнать результат». Отдельный обработчик продолжает работу, а клиент периодически читает состояние. Такой обмен называют опросом состояния (status polling).

Проследим одну задачу job-456:

t+0s POST /images → 202 Accepted, processing t+3s GET /image-jobs/job-456 → 200 OK, processing t+8s GET /image-jobs/job-456 → 200 OK, ready + URL

Наша цель — не просто повторять GET, а спроектировать устойчивый контракт: где хранится состояние, что означает каждый код HTTP, как пережить повторный POST и когда удалить старую задачу.

POST принимает работу, но не изображает её завершённой

Клиент отправляет файл и необязательный ключ идемпотентности:

POST /api/v1/images HTTP/1.1 Content-Type: multipart/form-data; boundary=... Idempotency-Key: 91c42e1d-8b4a-45a0-a0ec-4c81b933e423

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

HTTP/1.1 202 Accepted Location: /api/v1/image-jobs/job-456 Retry-After: 3 Content-Type: application/json { "job_id": "job-456", "status": "processing", "message": "Изображение обрабатывается" }

202 Accepted говорит: сервер принял операцию, но ещё не завершил её. Location показывает ресурс состояния, а Retry-After: 3 предлагает подождать три секунды перед первым опросом. Это подсказка, а не обещанное время готовности.

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

Маршрут состояния читает одну конкретную задачу

Через три секунды клиент запрашивает адрес из Location:

GET /api/v1/image-jobs/job-456 HTTP/1.1

Если запись существует, сам HTTP-запрос выполнен успешно, поэтому маршрут отвечает 200 OK. Фаза фоновой операции находится в JSON:

HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-store { "job_id": "job-456", "status": "processing", "created_at": "2026-07-15T10:00:00Z" }

Позже тот же маршрут возвращает готовый результат:

{ "job_id": "job-456", "status": "ready", "image_url": "https://storage.example/uploads/asset-456/display.jpg", "thumb_url": "https://storage.example/uploads/asset-456/thumb.jpg", "created_at": "2026-07-15T10:00:00Z", "finished_at": "2026-07-15T10:00:08Z" }

Или безопасно описанную бизнес-ошибку:

{ "job_id": "job-456", "status": "failed", "error": { "code": "invalid_image", "message": "Не удалось обработать изображение" }, "created_at": "2026-07-15T10:00:00Z", "finished_at": "2026-07-15T10:00:02Z" }

failed здесь тоже получает 200: чтение ресурса состояния прошло успешно, а ошибка относится к фоновой работе. Не превращайте каждое неуспешное состояние задачи в 500, иначе клиент не отличит нормальный завершённый результат от поломки маршрута состояния.

Набор полей остаётся предсказуемым: у processing нет результата, у ready есть URL, у failed — безопасный объект error, а у обоих терминальных состояний заполнен finished_at. Клиент должен уметь обрабатывать неизвестный будущий статус, а не считать его автоматически готовым.

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

Состояния образуют конечный автомат

В компактной модели задача начинает жизнь в processing и заканчивается ровно одним терминальным состоянием:

processing ──успех──▶ ready └────ошибка────▶ failed

processing может объединять ожидание в RabbitMQ и непосредственную работу. Если пользователю важно различать эти фазы, добавьте queued; не пытайтесь вычислять начало обработки по времени создания.

package imagejobs import "time" type Status string const ( StatusProcessing Status = "processing" StatusReady Status = "ready" StatusFailed Status = "failed" ) type Problem struct { Code string `json:"code"` Message string `json:"message"` } type Response struct { JobID string `json:"job_id"` Status Status `json:"status"` ImageURL string `json:"image_url,omitempty"` ThumbURL string `json:"thumb_url,omitempty"` Error *Problem `json:"error,omitempty"` CreatedAt time.Time `json:"created_at"` FinishedAt *time.Time `json:"finished_at,omitempty"` } func (r Response) Terminal() bool { return r.Status == StatusReady || r.Status == StatusFailed }

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

Для приватного результата храните стабильный ключ объекта, а предварительно подписанную ссылку (presigned URL) создавайте при чтении статуса. Сохранённая в БД временная ссылка истечёт, хотя задача останется ready.

Реестр состояния живёт вне обработчика

RabbitMQ переносит команду, но не отвечает на вопрос клиента «что сейчас с job-456?». Обработчик тоже не подходит как реестр: процесс можно перезапустить или масштабировать до нескольких экземпляров.

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

Создание проходит через несколько систем:

  1. проверить размер, тип файла и право пользователя загружать данные;
  2. сохранить оригинал в MinIO под ключом объекта с asset-456;
  3. транзакцией БД создать задачу job-456 в состоянии processing;
  4. передать команду с job_id, asset_id и ключом объекта в RabbitMQ;
  5. ответить 202 только после надёжного принятия команды.

MinIO, PostgreSQL и RabbitMQ не участвуют в одной ACID-транзакции. При прямой публикации небольшой сервис может пометить запись как failed и запланировать очистку оригинала, если RabbitMQ отказал. В таком варианте успешная публикация должна опираться на подтверждение публикации (publisher confirm), а не только на отсутствие локальной ошибки Publish. Более надёжный вариант — записать событие в транзакционный журнал исходящих событий (outbox) одной транзакцией с processing; отдельный издатель доставит его в RabbitMQ и безопасно повторит попытку.

Даже транзакционный журнал не включает MinIO в транзакцию. Если запись в БД не удалась после загрузки, объект становится сиротой и должен попасть под периодическую очистку.

Обработчик завершает только допустимый переход

RabbitMQ может повторно доставить команду job-456. Второй обработчик не должен вернуть готовую задачу в processing или создать ещё один независимый результат.

Обновление состояния выполняйте условно и атомарно:

UPDATE image_jobs SET status = 'ready', image_path = $2, thumb_path = $3, completed_at = NOW() WHERE id = $1 AND status = 'processing';

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

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

Обработчик HTTP только читает состояние

Слой чтения находит запись по jobID. Неизвестная или недоступная текущему пользователю задача возвращает 404; существующие processing, ready и failed200 с соответствующим JSON.

type StatusReader interface { FindByID( ctx context.Context, jobID string, ) (imagejobs.Response, error) } type StatusHandler struct { statuses StatusReader } func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { jobID := r.PathValue("jobID") status, err := h.statuses.FindByID(r.Context(), jobID) if errors.Is(err, ErrNotFound) { http.Error(w, "job not found", http.StatusNotFound) return } if err != nil { http.Error(w, "cannot read job status", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(status); err != nil { slog.WarnContext(r.Context(), "write job status", "error", err) } }

Фрагмент использует context, encoding/json, errors, log/slog, net/http и пакет imagejobs; ErrNotFound принадлежит слою данных. StatusReader скрывает внутреннюю запись и возвращает уже безопасную структуру ответа (DTO). На Go 1.22+ маршрут можно зарегистрировать как GET /api/v1/image-jobs/{jobID}.

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

Клиент опрашивает с паузой и умеет остановиться

Запрос каждые 50 миллисекунд не ускорит обработчик, но создаст нагрузку на API. Клиент начинает с Retry-After, затем увеличивает паузу, например 3, 5, 8 и максимум 15 секунд. Небольшой случайный разброс (jitter) не даёт тысячам клиентов проснуться одновременно.

Опрос прекращается, когда:

  • получен ready или failed;
  • маршрут вернул 404, 410 или окончательный отказ доступа;
  • истёк установленный клиентом срок ожидания;
  • пользователь покинул экран и запрос отменён.

Для редкой задачи опрос проще, чем WebSocket или события от сервера: он использует обычный HTTP и естественно переживает краткий сетевой разрыв. Если нужны частые обновления в реальном времени для тысяч клиентов, стоимость постоянных GET может оправдать модель с отправкой обновлений сервером.

Общая схема 202 + Location + Retry-After также описана в шаблоне асинхронного запроса и ответа (Async Request-Reply).

Повторный POST возвращает прежнюю попытку

Сервер мог создать job-456 и отправить 202, но ответ потерялся по сети. Клиент не знает об успехе и повторяет загрузку. Без отдельного механизма появятся две записи, два сообщения и два набора объектов.

Idempotency-Key связывает повторы одной команды. Сервер хранит уникальное сочетание user_id + operation + idempotency_key и отпечаток значимых входных данных:

  • тот же ключ и тот же отпечаток возвращают прежний job_id и текущее состояние;
  • тот же ключ с другим файлом или параметрами получает 409 Conflict;
  • новый ключ создаёт новую попытку.

Запись ключа имеет срок, согласованный с окном повторов клиента. Идемпотентность POST не заменяет идемпотентность обработчика: HTTP-команда и доставка RabbitMQ могут повториться независимо.

У результата есть срок хранения

Без ограничения срока хранения таблица задач, ключи идемпотентности и объекты будут расти бесконечно. До запуска определите:

  • когда слишком долгое processing считается зависшим;
  • сколько хранятся ready и failed;
  • сколько действует ключ идемпотентности;
  • когда маршрут состояния возвращает 404, а когда 410 Gone;
  • кто удаляет оригинал и производные объекты из MinIO.

Периодическая очистка должна быть идемпотентной. Если объект уже удалён, следующий запуск всё равно завершает удаление записи. Для отменяемой операции понадобятся cancelling/cancelled, команда отмены и кооперативные проверки обработчика между безопасными шагами; сервер не может отменить уже завершившийся внешний вызов задним числом.

Что проверить перед использованием

  • POST возвращает 202, стабильный job_id, Location и разумный Retry-After только после надёжного принятия работы.
  • Маршрут состояния является отдельным ресурсом и отвечает 200 для найденных processing, ready и failed.
  • JSON каждого состояния имеет предсказуемые поля и не раскрывает внутреннюю ошибку.
  • Запись состояния переживает перезапуск обработчика в соответствии с требованиями продукта.
  • Повторный POST и повторная доставка RabbitMQ не создают второй результат.
  • Разрывы между MinIO, БД и RabbitMQ закрыты компенсацией, очисткой и/или транзакционным журналом исходящих событий.
  • Опрос постепенно увеличивает паузу и прекращается в терминальном состоянии.
  • Срок хранения согласован для записи, ключа идемпотентности и объектов.

Доставку команды обработчику разбирает статья «RabbitMQ в Go: публикация и обработка сообщений», а хранение файлов — «S3 и MinIO в Go: работа с объектами».

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи