fix: speed up handbook sync and stop llm_calls 404 noise

Handbook sync ran files sequentially under a 5 req/min limiter with a
hardcoded 60s LLM timeout, causing long syncs and AbortError timeouts on
large files. Now: limiter at 20 req/min, files processed with concurrency
4, handbook extraction timeout raised to 180s, and near-empty files skip
the LLM call.

callLLM gains a timeoutMs option; passing a signal no longer silently
disables the per-request timeout.

llm_calls telemetry self-disables after the first 404 so deploys without
the migration applied don't spam the console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-05-22 16:35:56 +02:00
parent a38ad5d1e0
commit 25cae2fea9
4 changed files with 64 additions and 21 deletions

View File

@@ -67,6 +67,7 @@ export async function analyzeHandbookDelta(fileContent, filePath, { signal } = {
tools: [EMIT_HANDBOOK_DELTA_TOOL],
toolChoice: { type: 'tool', name: EMIT_HANDBOOK_DELTA_TOOL.name },
maxTokens: 8192,
timeoutMs: 180_000,
limiter: extractionLimiter,
signal,
});

View File

@@ -112,9 +112,16 @@ function buildMessages({ messages, user }) {
throw new Error('callLLM requires either `messages` or `user`.');
}
// Telemetry collection is optional. If the migration hasn't been applied on a
// given deploy, the first POST returns 404; we then disable further attempts
// to keep the console clean and avoid wasted round-trips.
let llmCallsDisabled = false;
function logLlmCall(record) {
if (llmCallsDisabled) return;
try {
pb.collection('llm_calls').create(record).catch(() => {});
pb.collection('llm_calls').create(record).catch((err) => {
if (err?.status === 404) llmCallsDisabled = true;
});
} catch {
/* collection may not exist yet — swallow */
}
@@ -271,6 +278,7 @@ function validateToolInputs(toolUses, task, toolSchemas) {
* @property {Record<string, import('zod').ZodTypeAny>} [toolSchemas] Overrides for tool_use input validation.
* @property {number} [maxTokens=4096]
* @property {number} [temperature=0]
* @property {number} [timeoutMs=60000] Per-request timeout in ms. Increase for large structured extractions.
* @property {AbortSignal} [signal]
* @property {{ acquire: (opts?:{signal?:AbortSignal}) => Promise<void>, pauseUntil: (untilMs:number) => void }} [limiter]
*/
@@ -291,6 +299,7 @@ export async function callLLM(options) {
toolSchemas,
maxTokens = 4096,
temperature = 0,
timeoutMs = DEFAULT_TIMEOUT_MS,
signal,
limiter,
} = options;
@@ -321,9 +330,9 @@ export async function callLLM(options) {
result = await withRetry(
async () => {
if (limiter) await limiter.acquire({ signal });
const timeoutCtl = signal ? null : new AbortController();
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
const timeoutCtl = new AbortController();
const timer = setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), timeoutMs);
const fetchSignal = linkSignals(signal, timeoutCtl.signal);
try {
const response = await fetch(ANTHROPIC_URL, {

View File

@@ -160,10 +160,12 @@ export function createLimiter({ rps = 1, burst = 1 } = {}) {
/**
* Shared limiter for the multi-call extraction loops (source chunks,
* handbook file sync). 5 requests/minute matches the lowest published
* Anthropic tier so we stay well clear of 429.
* handbook file sync). 20 req/min sits comfortably under Anthropic Tier 1
* (50/min for Sonnet) and lets the handbook sync finish in a reasonable
* time without us redesigning the rate-limit story; if a 429 still slips
* through, `withRetry` honours Retry-After.
*/
export const extractionLimiter = createLimiter({ rps: 5 / 60, burst: 1 });
export const extractionLimiter = createLimiter({ rps: 20 / 60, burst: 2 });
/**
* Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request