# Common.Platform.AsyncFallback — Универсальный helper для таймаутов асинхронных операций

**Автор:** Art Yg  
**Версия:** 1.0

`AsyncFallback` — это платформенный helper, который решает одну практическую проблему:  
**как безопасно обработать ситуацию “мы отправили async-запрос, но ответ может не прийти вовремя или вообще не прийти”**.

Он нужен для любых операций, где есть “PHASE 1 → отправили запрос” и “PHASE 2 → пришёл callback”:

- LLM (Remote LLM Query)
- ImageGen (генерация изображений)
- STT / Voice Transcription
- любые RemoteApiCall (внешние API, webhook processor, интеграции)

`AsyncFallback` **не является AI-модулем**. Это оркестрация платформенного уровня.

---

## Зачем он существует

В реальном мире async-вызовы ломаются не потому что “код плохой”, а потому что:

- провайдер завис / долго отвечает
- callback потерялся на транспорте
- webhook processor упал
- ответ пришёл, но неполный (нарушение контракта)
- пользователь продолжает писать, пока операция ещё не завершилась

Без таймаута сценарий может **залипнуть**: пользователь пишет, а система “ждёт” бесконечно.

`AsyncFallback` решает это через стандартный механизм Metabot Scheduler:

1. **Планирует job**: “через N секунд выполнить fallback-script”
2. **Отменяет job**, когда callback успешно пришёл
3. Позволяет фиксировать **причину ошибки** и **детали**, чтобы дальше можно было принять решение (редирект, retry, сообщение пользователю)

---

## Основные принципы

- **Platform-layer** — не привязан к AI, применяется везде
- **Namespace-based** — несколько параллельных async-операций не конфликтуют
- **Opt-in** — если timeout не задан, ничего не планируется
- **Side-effect only** — не вмешивается в бизнес-логику, только планирует/снимает job и пишет маркеры
- **Debug-friendly** — сохраняет конфиг и last_reason/last_details в lead (по namespace)

---

## Минимальные требования

Чтобы использовать `AsyncFallback`, нужно:

1. Иметь доступ к **планировщику** Metabot:
   - `bot.scheduleJob({ lead_id, script_code, run_after_sec })`
   - `bot.clearJobsByScriptCode(script_code, lead_id)`
2. Иметь `leadId` (или возможность вычислить lead_id)

`AsyncFallback` не требует базы данных, таблиц или дополнительных сервисов.

---

## Концепция Namespace

**Namespace** — обязательный “scope” для идентификации конкретной операции.

Это важно, потому что на одном lead могут одновременно идти:

- ImageGen (ожидаем картинку)
- LLMQuery (ожидаем JSON)
- STT (ожидаем текст транскрипции)

Если бы мы хранили “timeout.script” без namespace — они бы перетирали друг друга.

Пример namespace:

- `orion_image_reflection`
- `llm_actor_profile_extract`
- `voice_transcription_q1`

---

## Конфигурация

`AsyncFallback` конфигурируется на вызове:

- `namespace` — обязательно
- `timeout` — опционально
- `error` — опционально
- `storageRoot` — почти всегда не трогаем

### Поля конфигурации

- `timeout.seconds` — через сколько секунд считать операцию “просроченной”
- `timeout.script` — какой скрипт выполнить по истечении таймаута
- `error.flagAttr` — атрибут флага ошибки на lead (например `true/false`)
- `error.reasonAttr` — атрибут причины ошибки (строка)

---

## Как использовать

### PHASE 1 — отправка async-запроса (isFirstImmediateCall)

1) Конфигурируем fallback  
2) Планируем таймаут  
3) Отправляем remote request  
4) Возвращаем `false` (ждём callback)

```js
const AsyncFallback = require("Common.Platform.AsyncFallback");

const fb = AsyncFallback.configure({
  lead,
  namespace: "orion_image_reflection",
  timeout: { seconds: 120, script: "Orion_Image_Timeout" },
  error: { flagAttr: "orion_image_error", reasonAttr: "orion_image_error_reason" }
});

fb.schedule();

// ... RemoteApiCall.send(..., asyncResponse: true)
return false;
```

---

### PHASE 2 — пришёл callback

1) Снимаем таймаут (если он был)  
2) Валидируем результат  
3) Если контракт нарушен — `fail(reason, details)`  
4) Дальше ты решаешь: редиректить в error-script или вернуться “в ту же точку”

```js
const AsyncFallback = require("Common.Platform.AsyncFallback");

const fb = AsyncFallback.configure({
  lead,
  namespace: "orion_image_reflection"
});

fb.unschedule();

// если ответ плохой (например нет url при requireUrl=true)
fb.fail("url_missing", { requireUrl: true });

// твоя стратегия выхода:
return bot.run({ script_code: "Orion_Image_Error" });
```

---

## Методы

### `AsyncFallback.configure(params) → instance`

Создаёт instance и **сохраняет конфиг** в lead под namespace.  
Главная точка входа. Используй и в PHASE 1, и в PHASE 2 — единообразно.

---

### `instance.schedule() → boolean`

Планирует fallback-job, если `timeout.seconds` и `timeout.script` заданы.

Поведение:

- если timeout не задан → возвращает `false`, не считается ошибкой
- перед планированием **снимает предыдущие jobs** этого script_code для lead (защита от дублей)
- выставляет служебный флаг `active=1`

---

### `instance.unschedule() → boolean`

Отменяет запланированный fallback-job (если он был).

Поведение:

- если script неизвестен → `false`
- снимает `active=0` даже если clearJobs вернул false (чтобы состояние не “залипало”)

---

### `instance.fail(reason, details?) → true`

Фиксирует ошибку и причину:

- `error.flagAttr = true` (если задан)
- `error.reasonAttr = reason` (если задан)
- дополнительно пишет namespace-атрибуты:
  - `last_reason`
  - `last_details` (JSON/string)

Это **не редирект** и **не exception** — это маркер, после которого ты сам решаешь, что делать.

---

### `instance.clear() → true`

Очищает служебные данные namespace:

- config
- script/seconds/active
- last_reason/last_details

Полезно после успешного завершения операции (опционально).

---

## Типовой паттерн: “пользователь пишет, пока ждём”

`AsyncFallback` — про таймаут, но он закрывает важный кусок UX:

- если пользователь продолжает писать, пока async-операция ещё ждёт callback,
  ты показываешь “⏳ ждите…”
- если callback так и не пришёл — fallback-job переведёт сценарий в timeout-script

Обычно это делается так:

- **проверяешь** `payload.is_async_response`
- если это не callback — отправляешь `processing` и возвращаешь `false`
- callback → `unschedule()`

(Эта логика живёт в конкретном плагине типа ImageGen/LLMQuery, а AsyncFallback даёт им общий таймаутный механизм.)

---

## Когда использовать

- есть async callback и риск “зависнуть”
- важно гарантировать, что сценарий не будет ждать бесконечно
- нужен единый механизм таймаута для разных компонентов
- хочешь стандартизировать “timeout-script” как часть контракта компонента

---

## Когда не использовать

- операция строго синхронная
- у операции нет понятного “deadline” (таймаут не имеет смысла)
- ты уже используешь другой механизм оркестрации таймаутов, и второй будет конфликтовать

---

## Практические замечания, чтобы не словить редкий ад

### Поздний callback после таймаута

Может случиться: таймаут-скрипт уже отработал, а потом всё же прилетел success-callback.  
`AsyncFallback` снимает job на callback, но **если job уже выполнился**, снять уже нечего.

Правильный паттерн на уровне компонента:

- timeout-script ставит флаг `*_timed_out = true`
- callback-обработчик проверяет флаг и **игнорирует поздний успех** (или делает компенсацию)

Это не обязанность `AsyncFallback`, потому что стратегия зависит от бизнес-логики.

---

## Итог

`Common.Platform.AsyncFallback` — простой, но критически полезный слой платформенной оркестрации:

- даёт **гарантию выхода** из ожидания
- позволяет вести **несколько async-операций параллельно**
- стандартизирует **timeout и error markers**
- не превращается в “монолитный менеджер всего” — остаётся лёгким helper’ом