Менеджер нажал «Разобрать звонок», экран крутится минуту и показывает ошибку. Он нажимает ещё раз. Через несколько минут в сделке лежат два комментария, две задачи и два разных черновика письма. Модель здесь могла отработать нормально. Сломалась граница между долгим запросом и бизнес-операцией.
Первое, что я бы проверил, — что именно ваша система называет таймаутом. Таймер в интерфейсе, deadline backend и реальная отмена HTTP-запроса — три разные вещи. Если браузер перестал ждать, сервер может всё ещё получить ответ и записать его в CRM.
Сначала дайте операции собственный ID
Создайте operation_id до вызова модели. Он описывает не HTTP-попытку, а бизнес-действие: «подготовить разбор звонка 18452 версии 1». Повторный запрос должен прийти с тем же ID и увидеть уже начатую или завершённую операцию.
const operationId = 'call:' + callId + ':analysis:v1';
const current = await operations.find(operationId);
if (current?.status === 'DONE') return current.result;
if (current?.status === 'STARTED') return { status: 'processing' };
await operations.insert({ id: operationId, status: 'STARTED' });
Случайный UUID на каждую попытку не помогает: backend видит разные операции и честно выполняет обе. Стабильным должен быть ключ эффекта, а не сетевого запроса.
Ограничьте ожидание и действительно отмените запрос
В Node.js можно передать AbortSignal.timeout() в клиент, который поддерживает signal. Отдельный setTimeout без abort только запустит функцию по времени; соединение продолжит жить.
const deadlineMs = 45_000;
try {
const response = await openai.responses.create(
{ model: process.env.OPENAI_MODEL, input },
{ signal: AbortSignal.timeout(deadlineMs) }
);
await operations.complete(operationId, response.id, response.output_text);
} catch (error) {
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
await operations.markUnknown(operationId, 'deadline_exceeded');
throw new Error('AI_DEADLINE_EXCEEDED');
}
throw error;
}
Статус после timeout лучше назвать UNKNOWN, а не FAILED. В распределённой системе вы не всегда знаете, успел ли удалённый сервис завершить работу. Перед повтором нужен отдельный шаг сверки.
Разделите повтор AI и повтор записи в CRM
Ответ модели и запись в CRM — разные этапы. Сохраните сырой идентификатор ответа и нормализованный результат до CRM-вызова. Если CRM временно недоступна, повторяйте только запись, не оплачивайте второй AI-запрос.
| Состояние | Что известно | Следующее действие |
|---|---|---|
| STARTED | Запрос начат | Не запускать второй экземпляр |
| UNKNOWN | Deadline истёк | Сверить ответ и журналы |
| AI_DONE | Результат сохранён | Повторить только CRM-шаг |
| DONE | CRM-ID записан | Вернуть сохранённый итог |
Контрольный тест с искусственной задержкой
- Создайте тестовую сделку и зафиксируйте число задач и комментариев.
- На тестовом proxy задержите ответ дольше deadline.
- Отправьте операцию и сразу повторите её с тем же
operation_id. - После снятия задержки сравните журнал операций и CRM.
- PASS: первая попытка завершается контролируемой ошибкой, а повтор получает статус processing или сохранённый результат.
- PASS: в CRM появился ровно один объект с ID, записанным в журнале.
- FAIL: timeout переводит запись в FAILED, после чего retry создаёт второй эффект.
- FAIL: закрытие вкладки меняет судьбу серверной операции.
Ошибки, которые дают дубли
Таймер только в UI. Пользователь видит ошибку и повторяет действие, пока первый backend продолжает работать.
Retry всего конвейера. Из-за сбоя CRM повторяется дорогой AI-анализ и получается другой текст. Повторяйте только упавший этап.
Новый ключ на каждую попытку. Такая схема отслеживает запросы, но не защищает бизнес-эффект.
Нет финального CRM-ID. Без него нельзя доказать, что операция уже завершена, и безопасно вернуть тот же результат.
Чек-лист перед production
- Есть стабильный operation_id.
- HTTP-вызов получает abort signal.
- После timeout сохраняется неопределённое состояние.
- AI-результат отделён от CRM-записи.
- Повтор проверяет существующий эффект.
- Логи не содержат API-ключи и персональные данные целиком.
Что записывать в журнал операции
Минимальная запись содержит operation_id, fingerprint входа, модель, время начала, deadline, номер попытки, внешний response ID, итоговый статус и CRM-ID. Не складывайте туда полный prompt и токен доступа. Для расследования обычно достаточно хеша входных данных и маскированного фрагмента ошибки.
Добавьте метрику по состояниям UNKNOWN и длительности каждого этапа. Если растёт только время AI, меняйте deadline или переводите сценарий в фон. Если AI_DONE стабильно быстрый, а задержка появляется перед DONE, модель ни при чём: смотрите CRM, очередь или блокировку базы.
Когда синхронный запрос уже не подходит
Если пользовательская операция регулярно живёт дольше времени ответа интерфейса, не растягивайте HTTP timeout до нескольких минут. Верните 202 Accepted, покажите operation_id и опрашивайте отдельный status endpoint. Backend завершит работу один раз, а браузер сможет закрыться и открыться снова без повторного запуска.
Что делать, если не помогло
На время отключите автоматический retry. Возьмите один operation_id и соберите четыре отметки времени: вход backend, отправка AI, получение ответа, запись CRM-ID. Если ответа нет, проверяйте transport и deadline. Если ответ есть, а CRM пустая, проблема ниже по цепочке. Для ограничения частоты используйте безопасный retry после 429, а для повторных событий — идемпотентную обработку webhook. CTR1 может разобрать AI-контур и его запись в CRM на тестовом сценарии.



