fix(ai-moderation): implement exponential backoff for LLM and vision API retries

This commit is contained in:
MythEclipse
2026-06-04 13:02:02 +07:00
parent 1c46a8a084
commit c304d4ca7c
2 changed files with 92 additions and 54 deletions
@@ -107,8 +107,8 @@ export async function llmChat(
}, },
{ {
retries, retries,
minTimeout: 0, minTimeout: 500,
maxTimeout: 0, maxTimeout: 4_000,
factor: 2, factor: 2,
}, },
); );
@@ -1,5 +1,5 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { retryWithBackoff } from "@bete/shared/utils"; import { delay, retryWithBackoff } from "@bete/shared/utils";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import type { ChatCompletion } from "openai/resources/chat/completions"; import type { ChatCompletion } from "openai/resources/chat/completions";
import { AbortError } from "p-retry"; import { AbortError } from "p-retry";
@@ -161,7 +161,6 @@ function deriveRecommendedAction(
return "review"; return "review";
} }
/** /**
* Helper to extract JSON from a potentially conversational or markdown-wrapped string. * Helper to extract JSON from a potentially conversational or markdown-wrapped string.
*/ */
@@ -531,12 +530,15 @@ const visionLruCache = new LRUCache<string, string>({
* share the same promise, eliminating the race condition between cache * share the same promise, eliminating the race condition between cache
* check and cache write. * check and cache write.
*/ */
const inFlightVisionCalls = new Map<string, Promise<string | null>>(); const inFlightVisionCalls = new Map<string, Promise<string>>();
const FAILED_ANALYSIS_PREFIX =
"GAGAL DIANALISIS — gambar tidak dapat diunduh atau vision API gagal setelah 3x percobaan. JANGAN mengasumsikan gambar aman hanya karena gagal dianalisis. Gunakan metadata URL/nama file saja sebagai petunjuk.";
const analyzeSingleMediaImage = async ( const analyzeSingleMediaImage = async (
messageId: string, messageId: string,
image: MessageImagePart, image: MessageImagePart,
): Promise<string | null> => { ): Promise<string> => {
const cacheKey = image.customEmojiId const cacheKey = image.customEmojiId
? makeCustomEmojiCacheKey(image.customEmojiId) ? makeCustomEmojiCacheKey(image.customEmojiId)
: image.stickerName : image.stickerName
@@ -567,7 +569,7 @@ const analyzeSingleMediaImage = async (
"Media analysis in-flight dedupe — waiting for existing call", "Media analysis in-flight dedupe — waiting for existing call",
); );
const result = await existing; const result = await existing;
if (!result) return null; // result is never null — the promise always returns a descriptive string
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${result}`; return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${result}`;
} }
@@ -577,7 +579,7 @@ const analyzeSingleMediaImage = async (
? buildCustomEmojiVisionPrompt(image.customEmojiName, messageId) ? buildCustomEmojiVisionPrompt(image.customEmojiName, messageId)
: buildGeneralImageVisionPrompt(image.sourceLabel, messageId); : buildGeneralImageVisionPrompt(image.sourceLabel, messageId);
const visionPromise = (async (): Promise<string | null> => { const visionPromise = (async (): Promise<string> => {
// Layer 2: Perceptual hash pre-check (before expensive vision API call) // Layer 2: Perceptual hash pre-check (before expensive vision API call)
let phash: string | null = null; let phash: string | null = null;
if (image.image_url.url.startsWith("data:")) { if (image.image_url.url.startsWith("data:")) {
@@ -610,17 +612,23 @@ const analyzeSingleMediaImage = async (
} }
} }
// ── Vision API call with EXTERNAL exponential backoff ──
// Uses retryWithBackoff directly so each retry has proper backoff delay.
// llmVision calls llmChat which also has retryWithBackoff, but its
// inner backoff has minTimeout=0 (instant). Our outer backoff ensures
// meaningful delay between full attempts.
let lastError: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try { try {
const content = await llmVision(promptText, image.image_url); const content = await llmVision(promptText, image.image_url);
if (!content) return null; if (content) {
// Success — persist to all cache layers
await upsertCachedMediaAnalysis( await upsertCachedMediaAnalysis(
cacheKey, cacheKey,
content, content,
"vision_llm", "vision_llm",
Date.now() + 24 * 60 * 60 * 1000, Date.now() + 24 * 60 * 60 * 1000,
); );
// Populate in-memory LRU and phash cache
visionLruCache.set(cacheKey, content); visionLruCache.set(cacheKey, content);
if (phash) { if (phash) {
upsertCachedMediaByPhash( upsertCachedMediaByPhash(
@@ -630,29 +638,52 @@ const analyzeSingleMediaImage = async (
Date.now() + 7 * 24 * 60 * 60 * 1000, Date.now() + 7 * 24 * 60 * 60 * 1000,
).catch(() => {}); ).catch(() => {});
} }
return content; return content;
} catch (error) { }
// llmVision returned null (no API key / client unavailable) — no point retrying
log.warn(
{ messageId },
"Vision API client unavailable (null response) — skipping retry",
);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < 2) {
const backoffMs = Math.min(
1_000 * 2 ** attempt + Math.random() * 200,
8_000,
);
log.warn( log.warn(
{ {
messageId, messageId,
error: error instanceof Error ? error.message : String(error), attempt: attempt + 1,
backoffMs,
error: lastError.message,
}, },
"Vision analysis failed after retries — image not analyzed", "Vision API attempt failed — backing off before retry",
); );
// Return a clear signal that vision analysis FAILED — so batch LLM knows await delay(backoffMs);
// the image could not be described and must NOT assume it's clean.
return null;
} }
}
}
// All attempts exhausted — return descriptive failure text.
// NOT null: the caller must always have a descriptive string to
// inject into the prompt so the LLM knows the image was skipped.
log.warn(
{
messageId,
lastError: lastError?.message ?? "null response",
},
"Vision analysis failed after all retry attempts",
);
return FAILED_ANALYSIS_PREFIX;
})(); })();
inFlightVisionCalls.set(cacheKey, visionPromise); inFlightVisionCalls.set(cacheKey, visionPromise);
try { try {
const content = await visionPromise; const content = await visionPromise;
if (!content) {
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: GAGAL DIANALISIS — gambar tidak dapat diunduh atau vision API gagal setelah 3x percobaan. JANGAN mengasumsikan gambar aman hanya karena gagal dianalisis. Gunakan metadata URL/nama file saja sebagai petunjuk.`;
}
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${content}`; return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${content}`;
} finally { } finally {
inFlightVisionCalls.delete(cacheKey); inFlightVisionCalls.delete(cacheKey);
@@ -936,7 +967,10 @@ async function runTextOnlyBatch(
for (const [, members] of shortContentGroups) { for (const [, members] of shortContentGroups) {
if (members.length > 1) { if (members.length > 1) {
const rep = members[0]; const rep = members[0];
groupMapping.set(rep.id, members.map((m) => m.id)); groupMapping.set(
rep.id,
members.map((m) => m.id),
);
} }
} }
@@ -1033,11 +1067,15 @@ async function runTextOnlyBatch(
} }
)?.usage; )?.usage;
// Fan-out results from representative messages to all group members // Fan-out results from representative messages to all group members
const fannedOutResults = groupMapping.size > 0 const fannedOutResults =
groupMapping.size > 0
? batchResult.results.flatMap((result) => { ? batchResult.results.flatMap((result) => {
const members = groupMapping.get(result.messageId); const members = groupMapping.get(result.messageId);
if (members) { if (members) {
return members.map((memberId) => ({ ...result, messageId: memberId })); return members.map((memberId) => ({
...result,
messageId: memberId,
}));
} }
return [result]; return [result];
}) })
@@ -1429,7 +1467,7 @@ async function _runSingleMediaAnalysis(
Array.from(imageMap.entries()).flatMap(([msgId, images]) => Array.from(imageMap.entries()).flatMap(([msgId, images]) =>
images.map(async (image) => { images.map(async (image) => {
const summary = await analyzeSingleMediaImage(msgId, image); const summary = await analyzeSingleMediaImage(msgId, image);
if (!summary) return; // summary is never null — always returns either the analysis or a failure description
const existing = mediaAnalysisMap.get(msgId) ?? []; const existing = mediaAnalysisMap.get(msgId) ?? [];
existing.push(summary); existing.push(summary);
mediaAnalysisMap.set(msgId, existing); mediaAnalysisMap.set(msgId, existing);