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 явно сообщила failure | polza media failed: <reason> | ✅ да | Деньги не списаны, retry на другой модели оправдан |
| HTTP 4xx/5xx от Polza | polza media POST/GET <code> | ✅ да | Запрос не дошёл до генерации |
| Connection reset | connection 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 за единый
Providerinterface с переключателем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