fix: поиск по подресурсам + реальный per-call таймаут ЛЛМ

search(resourceBaseSegment): суффиксы-подресурсы перебираются по убыванию
длины (KNOWN_RESOURCE_SUBS). Иначе "_user" матчился раньше "_sub_user" и
nubes_s3_sub_user давал "s3_sub" вместо "s3" — страница ресурса не находилась.

assist(llmChat): реально применяет ASSIST_LLM_TIMEOUT_MS — таймаут ОДНОГО
вызова (покрывает и чтение тела ответа). Таймаут возвращает отдельный код
llm_timeout, отмена вызывающим (дедлайн/закрытие соединения) — по-прежнему
aborted, чтобы stage2 различал причины.

stage1: убран дублирующий AbortController (таймаут теперь только в llmChat).

orchestrator: llm_timeout -> HTTP 504.
This commit is contained in:
Repinoid
2026-10-12 07:51:48 +03:00
parent 5ab9274015
commit 12d29ee98d
4 changed files with 139 additions and 100 deletions
+45 -2
View File
@@ -266,7 +266,8 @@ function createSemaphore(max) {
* @param {{
* messages:Array, // массив сообщений {role, content}
* maxTokens?:number,
* controller?:AbortController, // сигнал отмены
* controller?:AbortController, // сигнал отмены вызывающего (дедлайн/закрытие соединения)
* timeoutMs?:number, // таймаут ОДНОГО вызова ЛЛМ (по умолч. ASSIST_LLM_TIMEOUT_MS)
* baseUrl?:string,
* apiKey?:string,
* model?:string,
@@ -280,6 +281,7 @@ async function llmChat({
messages,
maxTokens,
controller,
timeoutMs = ENV.ASSIST_LLM_TIMEOUT_MS,
baseUrl = ENV.LLM_BASE_URL,
apiKey = ENV.LLM_API_KEY,
model = ENV.LLM_MODEL,
@@ -301,8 +303,27 @@ async function llmChat({
payload.response_format = responseFormat;
}
const signal = controller ? controller.signal : undefined;
// Составной сигнал отмены. fetch принимает РОВНО ОДИН signal, поэтому, когда
// задан индивидуальный таймаут вызова (ASSIST_LLM_TIMEOUT_MS), создаём
// дочерний AbortController, который абортится ЛИБО по таймауту, ЛИБО при
// аборте вызывающего (дедлайн всего запроса / закрытие соединения клиентом).
// Без таймаута используем сигнал вызывающего напрямую. Дочерний контроллер
// покрывает ВЕСЬ вызов, включая чтение тела ответа (response.json()).
let signal = controller ? controller.signal : undefined;
let timeoutTimer = null;
let onParentAbort = null;
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
const ownController = new AbortController();
signal = ownController.signal;
if (controller) {
onParentAbort = () => ownController.abort();
if (controller.signal.aborted) ownController.abort();
else controller.signal.addEventListener('abort', onParentAbort, { once: true });
}
timeoutTimer = setTimeout(() => ownController.abort(), timeoutMs);
}
try {
let response;
try {
response = await fetch(url, {
@@ -316,8 +337,14 @@ async function llmChat({
});
} catch (err) {
if (err && err.name === 'AbortError') {
// Различаем причину: отмена вызывающим vs. собственный таймаут
// вызова (иначе stage2 не смог бы отличить таймаут от ухода
// клиента и корректно решить, повторять ли вызов).
if (controller && controller.signal.aborted) {
throw { code: 'aborted', message: 'llm request aborted (AbortError)' };
}
throw { code: 'llm_timeout', message: `llm call exceeded timeout ${timeoutMs} ms` };
}
throw new Error('llm request failed (network)');
}
@@ -367,6 +394,22 @@ async function llmChat({
}
return content;
} catch (err) {
// Аборт мог прилететь не только из fetch, но и при чтении тела ответа
// (response.json() выше) — нормализуем и этот случай тем же образом.
if (err && err.name === 'AbortError') {
if (controller && controller.signal.aborted) {
throw { code: 'aborted', message: 'llm request aborted (AbortError)' };
}
throw { code: 'llm_timeout', message: `llm call exceeded timeout ${timeoutMs} ms` };
}
throw err;
} finally {
// Снимаем таймер и слушателя родителя на всех путях (успех/ошибка),
// чтобы не оставлять висящих таймеров и подписок на сигнале.
if (timeoutTimer) clearTimeout(timeoutTimer);
if (controller && onParentAbort) controller.signal.removeEventListener('abort', onParentAbort);
}
}
/**
+1
View File
@@ -342,6 +342,7 @@ function mapPipelineError(code) {
case 'index_unavailable': return 503;
case 'empty_content': return 502;
case 'aborted': return 499;
case 'llm_timeout': return 504;
case 'rate_limited': return 503;
default: return 502;
}
+19 -8
View File
@@ -190,6 +190,22 @@ function pageKind(seg) {
return "resource";
}
/**
* Суффиксы-подресурсы в именах `nubes_*`, по которым отрезается хвост до
* базового ресурса (например, `nubes_postgres_database` → `postgres`).
*
* ⚠️ Порядок перебора критичен: суффиксы идут по УБЫВАНИЮ длины, поэтому
* сортируем массив один раз при загрузке модуля. Иначе короткий `_user`
* затеняет более длинный `_sub_user`: для `nubes_s3_sub_user` (rest =
* "s3_sub_user") отрезался бы `_user`, и получалось "s3_sub" вместо "s3",
* из-за чего страница ресурса не находилась (`isResourcePage`).
*/
const KNOWN_RESOURCE_SUBS = [
"_database", "_user", "_topic", "_backup", "_snat", "_record",
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
"_team", "_redeploy", "_group",
].sort((a, b) => b.length - a.length);
/**
* Преобразует имя ресурса nubes_<база>_<подресурс> в базовое имя страницы
* (<база>). Для `nubes_postgres_database` → `postgres`; `nubes_postgres` → `postgres`.
@@ -201,15 +217,10 @@ function resourceBaseSegment(resourceType) {
if (!resourceType || !resourceType.startsWith("nubes_")) return null;
const rest = resourceType.slice("nubes_".length);
if (!rest) return null;
// Известные подресурсы (суффиксы) — отрезаем до базового ресурса.
// Берём первую часть до суффикса-подресурса, если составное имя содержит известный подресурс.
const knownSubs = [
"_database", "_user", "_topic", "_backup", "_snat", "_record",
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
"_team", "_redeploy", "_group",
];
// Известные суффиксы-подресурсы; перебор идёт от самого длинного к самому
// короткому (порядок задан в KNOWN_RESOURCE_SUBS, см. определение выше).
let base = rest;
for (const sub of knownSubs) {
for (const sub of KNOWN_RESOURCE_SUBS) {
if (rest.endsWith(sub)) {
base = rest.slice(0, rest.length - sub.length);
break;
+7 -23
View File
@@ -131,38 +131,22 @@ async function stage1ExtractResourceType(question, {
const messages = buildStage1Messages(question, types);
const attempt = async (maxTokens) => {
const params = {
// Индивидуальный таймаут одного вызова ЛЛМ и составной сигнал отмены
// реализованы ВНУТРИ llmChat (единая точка). Здесь только прокидываем опции,
// чтобы не создавать ДВОЙНОЙ AbortController. timeoutMs === undefined →
// llmChat применит дефолт ASSIST_LLM_TIMEOUT_MS.
const attempt = async (maxTokens) => llmChat({
messages,
maxTokens,
temperature: 0,
responseFormat: { type: 'json_object' },
controller,
timeoutMs,
baseUrl,
apiKey,
model,
usageRef,
};
let timer = null;
let ctrl = controller;
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
ctrl = new AbortController();
timer = setTimeout(() => ctrl.abort(), timeoutMs);
const forward = () => ctrl.abort();
if (controller) {
if (controller.signal.aborted) ctrl.abort();
else controller.signal.addEventListener('abort', forward, { once: true });
}
params.controller = ctrl;
try {
return await llmChat(params);
} finally {
clearTimeout(timer);
if (controller) controller.signal.removeEventListener('abort', forward);
}
}
return llmChat(params);
};
});
let content;
try {