fix(backend): harden LLM error handling — retry, timeout, missing key guards

This commit is contained in:
saravanakumardb1 2026-04-06 11:09:08 -07:00
parent b8bc096adb
commit c71b01681f
3 changed files with 89 additions and 37 deletions

View File

@ -6,6 +6,13 @@
import { llm } from './llm.js'; import { llm } from './llm.js';
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
]);
}
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar' | 'fix-rewrite' | 'change-tone' | 'continue' | 'explain'; export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar' | 'fix-rewrite' | 'change-tone' | 'continue' | 'explain';
const SYSTEM_PROMPTS: Record<CopilotAction, string> = { const SYSTEM_PROMPTS: Record<CopilotAction, string> = {
@ -51,19 +58,30 @@ export async function runCopilotTransform(action: CopilotAction, text: string):
return fallbackTransform(action, text); return fallbackTransform(action, text);
} }
try { const maxRetries = 3;
const result = await provider.chatCompletion({ const baseDelayMs = 1000;
messages: [
{ role: 'system', content: SYSTEM_PROMPTS[action] }, for (let attempt = 0; attempt < maxRetries; attempt++) {
{ role: 'user', content: text }, try {
], const result = await withTimeout(provider.chatCompletion({
temperature: 0.3, messages: [
maxTokens: 4096, { role: 'system', content: SYSTEM_PROMPTS[action] },
}); { role: 'user', content: text },
const out = result.content.trim(); ],
if (out.length > 0) return out; temperature: 0.3,
} catch { maxTokens: 4096,
// fall through to local heuristics }), 60_000);
const out = result.content.trim();
if (out.length > 0) return out;
break; // empty response — fall through to heuristic
} catch (err: unknown) {
const isRateLimit = err instanceof Error && (err.message.includes('429') || err.message.includes('rate'));
if (isRateLimit && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
continue;
}
break; // non-retriable error — fall through to heuristic
}
} }
return fallbackTransform(action, text); return fallbackTransform(action, text);
} }
@ -76,18 +94,18 @@ export async function suggestTitleFromBody(body: string): Promise<string> {
} }
try { try {
const result = await provider.chatCompletion({ const result = await withTimeout(provider.chatCompletion({
messages: [ messages: [
{ role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' }, { role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' },
{ role: 'user', content: plain.slice(0, 4000) }, { role: 'user', content: plain.slice(0, 4000) },
], ],
temperature: 0.6, temperature: 0.6,
maxTokens: 64, maxTokens: 64,
}); }), 60_000);
const t = result.content.trim(); const t = result.content.trim();
if (t.length > 0 && t.length < 500) return t; if (t.length > 0 && t.length < 500) return t;
} catch { } catch {
// fall through // timeout or LLM error — fall through to heuristic
} }
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note'; return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
} }

View File

@ -11,6 +11,13 @@ import {
} from '@bytelyst/llm'; } from '@bytelyst/llm';
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js'; import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
]);
}
/** /**
* Interpolate {{variable}} placeholders in a template string. * Interpolate {{variable}} placeholders in a template string.
*/ */
@ -29,6 +36,10 @@ export async function executePrompt(
): Promise<RunPromptOutput> { ): Promise<RunPromptOutput> {
const provider = llm(); const provider = llm();
if (!provider.isConfigured()) {
throw new Error('LLM provider is not configured. Set LLM_PROVIDER and the required API key.');
}
// Build variables map // Build variables map
const vars: Record<string, string> = { const vars: Record<string, string> = {
...input.variables, ...input.variables,
@ -58,27 +69,49 @@ export async function executePrompt(
model = config.LLM_VISION_MODEL; model = config.LLM_VISION_MODEL;
} }
const result = await provider.chatCompletion({ const maxRetries = 3;
messages, const baseDelayMs = 1000;
model, let lastError: unknown;
temperature: template.temperature ?? 0.7,
maxTokens: template.maxTokens ?? 4096,
});
const output: RunPromptOutput = { for (let attempt = 0; attempt < maxRetries; attempt++) {
content: result.content, try {
model: result.model, const result = await withTimeout(provider.chatCompletion({
usage: result.usage, messages,
templateSlug: template.slug, model,
outputType: template.outputType, temperature: template.temperature ?? 0.7,
}; maxTokens: template.maxTokens ?? 4096,
}), 60_000);
// F27: Approval-gated actions — produce proposed state instead of applied if (!result.content || result.content.trim().length === 0) {
if (template.requiresApproval) { throw new Error('LLM returned empty response');
output.approvalState = 'proposed'; }
} else {
output.approvalState = 'applied'; const output: RunPromptOutput = {
content: result.content,
model: result.model,
usage: result.usage,
templateSlug: template.slug,
outputType: template.outputType,
};
// F27: Approval-gated actions — produce proposed state instead of applied
if (template.requiresApproval) {
output.approvalState = 'proposed';
} else {
output.approvalState = 'applied';
}
return output;
} catch (err: unknown) {
lastError = err;
const isRateLimit = err instanceof Error && (err.message.includes('429') || err.message.includes('rate'));
if (isRateLimit && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
continue;
}
break;
}
} }
return output; throw lastError instanceof Error ? lastError : new Error('LLM call failed after retries');
} }

View File

@ -212,8 +212,9 @@ export async function runSchedulerTick(): Promise<number> {
}); });
ran++; ran++;
} catch { } catch (err: unknown) {
// Log but don't break the loop const msg = err instanceof Error ? err.message : 'Unknown scheduler error';
process.stderr.write(`[scheduler] Failed to run schedule ${schedule.id}: ${msg}\n`);
} }
} }