Практика · Надёжность · Интеграции

Таймауты внешних API в цепочке AI-агента

Уровень: средний Время чтения: до 8 минут Результат: таймауты, fallback и информативные статусы

Один медленный сервис не должен удерживать весь сценарий. Если 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, трассировка одного запуска и ограничение параллелизма.

Отмена на стороне клиента также не гарантирует прекращение работы удалённого сервиса. Для операций записи используйте идемпотентные ключи и проверяемое состояние операции. Порог таймаута следует пересматривать по реальным наблюдениям: слишком короткий создаёт ложные отказы, слишком длинный возвращает исходную проблему блокировки.

Если независимые шаги можно запускать параллельно, это уменьшит суммарное ожидание, но потребует общего сигнала отмены и ограничения числа одновременных запросов. Не распараллеливайте шаги, между которыми есть зависимость данных или порядка.

Другие практические материалы собраны в разделе «Руководства». Определения терминов об агентах, инструментах и контексте доступны в глоссарии.