Практика · Надёжность · Интеграции
Таймауты внешних API в цепочке AI-агента
Один медленный сервис не должен удерживать весь сценарий. Если AI-агент последовательно обращается к профилю пользователя, правилам доступа и модели, зависшее API способно исчерпать всё допустимое время. Исправим это на локальном примере: зададим общий дедлайн операции, ограничим каждый вызов, предусмотрим fallback и отделим частичный результат от успешного.
Модель отказа
В примере агент выполняет два внешних шага. Профиль нужен только для персонализации, поэтому при сбое его можно заменить нейтральным значением. Проверка политики критична: если она недоступна, агент не имеет права выдумывать разрешение.
- Общий дедлайн: 1200 мс на всю обработку.
- Профиль: не более 400 мс, после чего применяется fallback.
- Политика: не более 500 мс и не дольше остатка общего дедлайна.
- Результат: итоговый статус и отдельный статус каждого шага.
Числа выбраны только для локальной демонстрации. В рабочей системе их определяют по целевому времени ответа и наблюдаемому распределению задержек, а не копируют из примера.
Шаг 1. Поднимите локальный стенд
Пример использует только встроенные возможности Node.js и не содержит клиентов, секретов или обращений во внешнюю сеть. Сохраните следующий код как server.mjs.
import http from "node:http";
const profileDelay = Number(process.env.MOCK_PROFILE_MS ?? 100);
const policyDelay = Number(process.env.MOCK_POLICY_MS ?? 100);
const server = http.createServer((request, response) => {
const routes = {
"/profile": {
delay: profileDelay,
body: { name: "Локальный пользователь" }
},
"/policy": {
delay: policyDelay,
body: { allowed: true }
}
};
const route = routes[request.url];
if (!route) {
response.writeHead(404).end();
return;
}
setTimeout(() => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(route.body));
}, route.delay);
});
server.listen(4000, "127.0.0.1", () => {
console.log("Mock API: http://127.0.0.1:4000");
});
Запустите сервер в отдельном терминале:
MOCK_PROFILE_MS=1500 MOCK_POLICY_MS=100 node server.mjs
Это безопасный локальный процесс: он слушает только 127.0.0.1. Профиль намеренно отвечает позже своего лимита, а политика остаётся быстрой.
Шаг 2. Ограничьте один HTTP-вызов
Сохраните следующий файл как agent.mjs. Функция создаёт собственный AbortController, прекращает ожидание по таймеру и всегда очищает таймер после завершения.
const API = "http://127.0.0.1:4000";
async function fetchJson(path, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error(`timeout after ${timeoutMs} ms`)),
timeoutMs
);
try {
const response = await fetch(`${API}${path}`, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timer);
}
}
function classifyError(error) {
const message = String(error?.message ?? error);
if (
error?.name === "AbortError" ||
message.includes("timeout after")
) {
return "timeout";
}
return "error";
}
Шаг 3. Добавьте общий дедлайн и fallback
Продолжите agent.mjs следующим кодом:
function remaining(deadline) {
return Math.max(0, deadline - Date.now());
}
async function runAgent() {
const startedAt = Date.now();
const deadline = startedAt + 1200;
const steps = {};
let profile = { name: "пользователь" };
try {
profile = await fetchJson(
"/profile",
Math.min(400, remaining(deadline))
);
steps.profile = { status: "ok" };
} catch (error) {
steps.profile = {
status: classifyError(error),
fallback: "neutral_profile"
};
}
const policyBudget = Math.min(500, remaining(deadline));
if (policyBudget === 0) {
steps.policy = { status: "skipped", reason: "deadline_exhausted" };
return {
status: "waiting_external_service",
message: "Не удалось проверить правила: общий дедлайн исчерпан.",
steps,
elapsedMs: Date.now() - startedAt
};
}
try {
const policy = await fetchJson("/policy", policyBudget);
steps.policy = { status: "ok" };
return {
status: steps.profile.status === "ok"
? "completed"
: "completed_with_fallback",
message: policy.allowed
? `Запрос разрешён для: ${profile.name}`
: "Запрос запрещён правилами.",
steps,
elapsedMs: Date.now() - startedAt
};
} catch (error) {
steps.policy = { status: classifyError(error) };
return {
status: "waiting_external_service",
message: "Проверка правил временно недоступна. Решение не принято.",
steps,
elapsedMs: Date.now() - startedAt
};
}
}
console.log(JSON.stringify(await runAgent(), null, 2));
Fallback применяется только к необязательному профилю. Критическая политика переводит обработку в явное состояние ожидания. Такой ответ честнее, чем completed с непроверенным решением.
Шаг 4. Проверьте три сценария
Медленный необязательный сервис
При уже запущенном сервере выполните:
node agent.mjs
Проверьте свойства результата, а не конкретное значение elapsedMs:
{
"status": "completed_with_fallback",
"steps": {
"profile": {
"status": "timeout",
"fallback": "neutral_profile"
},
"policy": {
"status": "ok"
}
}
}
Фрагмент выше показывает ожидаемую форму, а не заранее полученный результат измерения. Фактическое время зависит от окружения.
Все сервисы отвечают вовремя
Остановите локальный сервер сочетанием Ctrl+C и запустите его снова:
MOCK_PROFILE_MS=100 MOCK_POLICY_MS=100 node server.mjs
Новый запуск node agent.mjs должен вернуть completed, а оба шага — ok.
Зависла критическая проверка
Снова перезапустите сервер с другой задержкой:
MOCK_PROFILE_MS=100 MOCK_POLICY_MS=1500 node server.mjs
Теперь ожидается waiting_external_service, статус политики timeout и сообщение о том, что решение не принято. Это проверяет fail-closed поведение критического шага.
Контракт информативных статусов
Удобно разделять итог операции и технические состояния шагов:
completed— все необходимые данные получены;completed_with_fallback— ответ готов, но необязательная часть заменена;waiting_external_service— критическая зависимость недоступна, решение не принято;timeout— шаг превысил выделенное время;error— произошла иная ошибка запроса или разбора ответа;skipped— шаг не запускался, например из-за исчерпанного дедлайна.
Пользовательскому интерфейсу лучше показывать понятное сообщение, а машинные поля сохранять для маршрутизации и наблюдаемости. Не включайте в публичный ответ URL внутренних сервисов, заголовки авторизации или полные тексты ошибок с чувствительными данными.
Типовые ошибки
Одинаковый fallback для всех зависимостей
Нейтральное имя допустимо, выдуманное разрешение — нет. Для каждого шага заранее определите, можно ли продолжать без него и какое состояние считается безопасным.
Таймаут без отмены запроса
Простая гонка между запросом и таймером возвращает управление, но запрос может продолжить занимать соединение. Передавайте сигнал отмены HTTP-клиенту и очищайте таймер в finally.
Каждый шаг получает полный бюджет
Перед запуском вычисляйте остаток общего дедлайна. Иначе локальные лимиты складываются, а обещанное время ответа нарушается.
Все ошибки называются таймаутом
HTTP 500, неверный JSON, отказ соединения и превышение времени требуют разных метрик и действий. Минимум отделяйте timeout от error.
Автоматические повторы без проверки операции
Повтор чтения обычно проще обосновать, чем повтор операции с побочным эффектом. Для записи нужны правила идемпотентности, ограничение попыток и отдельный бюджет времени. Иначе retry увеличит задержку или повторит действие.
Статус «успех» при деградации
Если fallback влияет на качество ответа, отражайте это в итоговом статусе. Иначе нельзя отличить полноценный результат от частичного ни в интерфейсе, ни в метриках.
Ограничения и перенос в рабочую систему
Локальный стенд демонстрирует управление временем, но не моделирует балансировщик, очереди, DNS, пул соединений и ограничения конкретного API. В рабочем сценарии дополнительно понадобятся метрики длительности по шагам, счётчики fallback, трассировка одного запуска и ограничение параллелизма.
Отмена на стороне клиента также не гарантирует прекращение работы удалённого сервиса. Для операций записи используйте идемпотентные ключи и проверяемое состояние операции. Порог таймаута следует пересматривать по реальным наблюдениям: слишком короткий создаёт ложные отказы, слишком длинный возвращает исходную проблему блокировки.
Если независимые шаги можно запускать параллельно, это уменьшит суммарное ожидание, но потребует общего сигнала отмены и ограничения числа одновременных запросов. Не распараллеливайте шаги, между которыми есть зависимость данных или порядка.
Другие практические материалы собраны в разделе «Руководства». Определения терминов об агентах, инструментах и контексте доступны в глоссарии.