ADR-022: Polza — async polling + smart fallback вместо sync-блокировки

Статус

Accepted

Контекст

Photo Studio Service вызывает Polza.ai /v1/media для генерации фото. До 2026-05-08 интеграция работала в sync-режиме: POST держал TCP-соединение до завершения генерации (Polza обещает sync до 120 сек, потом async-flip).

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

Проблема 1: TCP RST через 120 сек

Каждый второй POST падал с read tcp ...: read: connection reset by peer спустя ~120 сек. В Polza dashboard статус генерации при этом «Выполнено» — то есть они успели сгенерировать и списать деньги, но HTTP-ответ до нашего клиента не доехал. Симптом классический для long-poll через CDN/балансировщик с idle-таймаутом 100-120 сек.

Проблема 2: Двойное списание на сетевые ошибки

Worker считал «connection reset» обычной ошибкой и запускал fallback-модель (GPT-image → Nano Banana). Polza генерила обе картинки и списывала за обе, даже если первая была успешной (но мы её не получили).

В Polza dashboard видны были последовательности:

GPT-5.4-image-2 — Выполнено — 8.00 ₽
Nano Banana 2  — Выполнено — 4.80 ₽   ← двойная оплата за один job

Финансовый риск

На каждый job, попавший на «плохую» сетевую секунду, мы платили 2× стоимость генерации при том что пользователь получал ноль картинок (первая «потерялась», второй worker помечал job как failed после нашего polling-таймаута).

Решение

1. Включить асинхронный режим Polza (async: true)

Добавить в каждый POST-payload:

{
  "model": "...",
  "async": true,
  "input": { ... }
}

Polza при этом сразу возвращает { id, status: "pending", model, created } за миллисекунды. Сама генерация идёт у них на фоне. Наш клиент далее опрашивает GET /v1/media/{id} каждые 3 сек до status: "completed" (или "failed").

Эффект: TCP-сессия живёт <1 сек на каждый запрос → idle-таймаут балансировщика никогда не срабатывает → connection reset исчезает.

2. «Умный» классификатор fallback-ошибок

В worker.callWithFallback добавлен isFallbackableError(err). Fallback срабатывает только если ошибка является «настоящим отказом Polza»:

Класс ошибкиПризнакFallbackПочему
Polza явно сообщила failurepolza media failed: <reason>✅ даДеньги не списаны, retry на другой модели оправдан
HTTP 4xx/5xx от Polzapolza media POST/GET <code>✅ даЗапрос не дошёл до генерации
Connection resetconnection reset by peer❌ нетPrimary мог уже сгенерировать → списание
Наш polling-таймаутpolling timeout, Не удалось сгенерировать❌ нетPolza могла ещё работать
Транспортi/o timeout, EOF, context deadline exceeded❌ нетТот же риск двойного списания
Неизвестные ошибкиdefault❌ нетConservative-by-default

Эффект: двойное списание на сетевые проблемы исключено. Fallback теперь покрывает только реальные модельные/контентные отказы (квота, NSFW-фильтр, 5xx).

3. Polling deadline = 15 минут

Расширили дедлайн polling-цикла с 3 до 15 минут (internal/ai/polza.go::poll). Покрывает наблюдённые worst-case латентности GPT-image в часы пиковой нагрузки (до ~10 мин). Worker concurrency поднята до 5, чтобы один долгий job не блокировал очередь.

4. Fallback по умолчанию выключен в env

AI_MODEL_FALLBACK без значения по умолчанию (config.go теперь читает напрямую через os.Getenv, не через getEnv с default-аргументом). Пустое значение в .env означает «без fallback» — раньше пустое подменялось хардкодом и фоллбек незаметно включался на той же primary-модели.

Последствия

Положительные

  • TCP RST устранён — POST живёт <1 сек, idle-таймауты не страшны
  • Никаких двойных списаний на сетевые проблемы — fallback только на реальные отказы
  • Long-running jobs покрыты — до 15 мин наблюдения за результатом
  • Парралелизм x2.5 — concurrency 2 → 5 без увеличения нагрузки на Polza (мы только poll-им)
  • UX: фронт показывает «Обычно 3-6 минут» и push-уведомление при завершении

Отрицательные

  • ⚠️ Polling overhead: каждый активный job = 1 GET-запрос на Polza каждые 3 сек. При concurrency=5 максимум ~1.7 RPS на их API, что заведомо в пределах rate-limit.
  • ⚠️ Polling timeout не == «деньги не сняты»: если генерация у Polza идёт 16 минут, мы помечаем job failed, а Polza позже всё-таки спишет. Митигация — пункт «Reconciliation worker» в Roadmap ниже.

Риски

  • Если Polza когда-нибудь уберёт async: true (не документировано как стабильный API) — наш polling-цикл перестанет работать. Митигация: smoke-тест в CI с реальной Polza раз в день.
  • Polling-таймаут 15 мин предполагает, что Polza никогда не генерит дольше. Метрика polza media polling timeout в логах — тревожный сигнал; если стабильно >0/час, поднять до 20-30 мин или внедрить reconciliation.

Альтернативы (отвергнуты)

ВариантПочему отвергли
Оставить sync, увеличить server keepaliveНе помогает — RST приходит со стороны Polza/CF, не с нашей
Webhook-callback от PolzaВ их доках не описан, придётся писать в саппорт. Polling работает сейчас
Менять провайдера (fal.ai/OpenRouter)Другой контракт, другой биллинг (USD), полная переделка internal/ai/. Оставлено как roadmap-опция
Хардкод fallback на любой errorЭто и был источник двойного списания

Roadmap (вне scope этого ADR)

  • Reconciliation worker: сохранять polza_task_id в БД, после polling-таймаута переводить job в новый статус polza_pending, в фоне опрашивать раз в 30 сек. Гарантирует, что любая генерация, за которую Polza списала деньги, в итоге доедет до пользователя. Estimate: ~1 день.
  • Provider abstraction: вынести Polza/OpenRouter/fal.ai за единый Provider interface с переключателем AI_PROVIDER=... в env. Позволит делать A/B по качеству и быстро уйти с Polza при сбоях.
  • Webhook поддержка (если Polza добавит): сменить poll → push, экономить ~15 GET-запросов на каждый job.

Конфигурация после внедрения

AI_BASE_URL=https://polza.ai/api/v1
AI_MODEL=google/gemini-3.1-flash-image-preview     # Nano Banana 2 — primary
AI_MODEL_FALLBACK=openai/gpt-5.4-image-2           # GPT — только на реальные отказы
WORKER_CONCURRENCY=5                                # параллельных активных jobs
WORKER_JOB_TIMEOUT_SEC=1080                         # 18 мин (15 мин poll + запас)
WORKER_MAX_RETRIES=1                                # одна попытка, без retry

Ссылки