Skip to content

Commit af6d47f

Browse files
authored
fix(search): retry transient highlight failures (firecrawl#4000)
* fix(search): retry transient highlight failures * fix(api): harden highlight retry handling * fix(api): reject malformed highlight batches
1 parent 044d5f9 commit af6d47f

6 files changed

Lines changed: 237 additions & 15 deletions

File tree

apps/api/src/search/highlight-model.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function mockFetchOnce(body: unknown, ok = true, status = 200) {
2727
}
2828

2929
afterEach(() => {
30+
vi.useRealTimers();
3031
vi.unstubAllGlobals();
3132
vi.clearAllMocks();
3233
});
@@ -172,17 +173,95 @@ describe("generateHighlightsBatch", () => {
172173

173174
it("returns null when the service errors", async () => {
174175
mockFetchOnce({ error: "boom" }, false, 500);
176+
const onFailure = vi.fn();
175177

176178
const out = await generateHighlightsBatch(
177179
"q",
178180
[{ id: "0", markdown: "md" }],
179-
{ logger },
181+
{ logger, onFailure },
180182
);
181183

182184
expect(out).toBeNull();
185+
expect(fetch).toHaveBeenCalledTimes(2);
186+
expect(onFailure).toHaveBeenCalledWith("http_5xx");
183187
expect(logger.warn).toHaveBeenCalled();
184188
});
185189

190+
it("retries one transient server failure", async () => {
191+
const fetchMock = vi
192+
.fn()
193+
.mockResolvedValueOnce({
194+
ok: false,
195+
status: 503,
196+
text: async () => "unavailable",
197+
})
198+
.mockResolvedValueOnce({
199+
ok: true,
200+
status: 200,
201+
json: async () => ({ pages: [] }),
202+
text: async () => "",
203+
});
204+
vi.stubGlobal("fetch", fetchMock);
205+
206+
const out = await generateHighlightsBatch(
207+
"q",
208+
[{ id: "0", markdown: "md" }],
209+
{ logger },
210+
);
211+
212+
expect(fetchMock).toHaveBeenCalledTimes(2);
213+
expect(out).toEqual(new Map());
214+
});
215+
216+
it.each([null, []])(
217+
"classifies malformed JSON response %j as invalid",
218+
async body => {
219+
mockFetchOnce(body);
220+
const onFailure = vi.fn();
221+
222+
const out = await generateHighlightsBatch(
223+
"q",
224+
[{ id: "0", markdown: "md" }],
225+
{ logger, onFailure },
226+
);
227+
228+
expect(out).toBeNull();
229+
expect(onFailure).toHaveBeenCalledWith("invalid_response");
230+
},
231+
);
232+
233+
it("does not retry after the request deadline aborts during backoff", async () => {
234+
vi.useFakeTimers();
235+
const fetchMock = vi.fn().mockImplementation(
236+
() =>
237+
new Promise(resolve =>
238+
setTimeout(
239+
() =>
240+
resolve({
241+
ok: false,
242+
status: 503,
243+
text: async () => "unavailable",
244+
}),
245+
29_990,
246+
),
247+
),
248+
);
249+
vi.stubGlobal("fetch", fetchMock);
250+
const onFailure = vi.fn();
251+
252+
const resultPromise = generateHighlightsBatch(
253+
"q",
254+
[{ id: "0", markdown: "md" }],
255+
{ logger, onFailure },
256+
);
257+
await vi.advanceTimersByTimeAsync(30_000);
258+
const out = await resultPromise;
259+
260+
expect(out).toBeNull();
261+
expect(fetchMock).toHaveBeenCalledTimes(1);
262+
expect(onFailure).toHaveBeenCalledWith("timeout");
263+
});
264+
186265
it("falls back to legacy per-page calls while the old service URL is configured", async () => {
187266
const fetchMock = vi
188267
.fn()

apps/api/src/search/highlight-model.ts

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ import { config } from "../config";
1010
// the in-cluster GCP Stage 1 service relies on cluster network isolation.
1111

1212
const REQUEST_TIMEOUT_MS = 30000;
13+
const MAX_BATCH_ATTEMPTS = 2;
14+
const RETRY_DELAY_MS = 50;
15+
16+
export type HighlightFailureReason =
17+
| "timeout"
18+
| "network"
19+
| "http_4xx"
20+
| "http_5xx"
21+
| "invalid_response"
22+
| "unknown";
23+
24+
class HighlightHttpError extends Error {
25+
constructor(
26+
readonly status: number,
27+
body: string,
28+
) {
29+
super(`highlight model HTTP ${status}: ${body.slice(0, 200)}`);
30+
}
31+
}
32+
33+
class HighlightInvalidResponseError extends Error {}
1334

1435
// One highlight entry as returned by the service. Field semantics are
1536
// intentionally left undocumented here.
@@ -47,6 +68,68 @@ interface HighlightResult {
4768
markdown: string;
4869
}
4970

71+
function failureReason(error: unknown): HighlightFailureReason {
72+
if (error instanceof HighlightHttpError) {
73+
return error.status >= 500 ? "http_5xx" : "http_4xx";
74+
}
75+
if (error instanceof SyntaxError) {
76+
return "invalid_response";
77+
}
78+
if (error instanceof HighlightInvalidResponseError) {
79+
return "invalid_response";
80+
}
81+
if (error instanceof Error && error.name === "AbortError") {
82+
return "timeout";
83+
}
84+
if (error instanceof TypeError) {
85+
return "network";
86+
}
87+
return "unknown";
88+
}
89+
90+
function waitForRetry(signal: AbortSignal): Promise<void> {
91+
if (signal.aborted) {
92+
return Promise.reject(signal.reason);
93+
}
94+
95+
return new Promise((resolve, reject) => {
96+
const onAbort = () => {
97+
clearTimeout(timer);
98+
reject(signal.reason);
99+
};
100+
const timer = setTimeout(() => {
101+
signal.removeEventListener("abort", onAbort);
102+
resolve();
103+
}, RETRY_DELAY_MS);
104+
signal.addEventListener("abort", onAbort, { once: true });
105+
});
106+
}
107+
108+
async function fetchBatchWithRetry(
109+
url: string,
110+
init: RequestInit,
111+
signal: AbortSignal,
112+
): Promise<Response> {
113+
let lastError: unknown;
114+
for (let attempt = 1; attempt <= MAX_BATCH_ATTEMPTS; attempt++) {
115+
try {
116+
const response = await fetch(url, init);
117+
if (response.status < 500 || attempt === MAX_BATCH_ATTEMPTS) {
118+
return response;
119+
}
120+
await response.body?.cancel().catch(() => undefined);
121+
lastError = new HighlightHttpError(response.status, "");
122+
} catch (error) {
123+
lastError = error;
124+
if (signal.aborted || attempt === MAX_BATCH_ATTEMPTS) {
125+
throw error;
126+
}
127+
}
128+
await waitForRetry(signal);
129+
}
130+
throw lastError;
131+
}
132+
50133
function requestHeaders(requestId?: string): Record<string, string> {
51134
const headers: Record<string, string> = {
52135
"Content-Type": "application/json",
@@ -74,6 +157,7 @@ export async function generateHighlightsBatch(
74157
logPayload?: boolean;
75158
allowLegacyFallback?: boolean;
76159
requestId?: string;
160+
onFailure?: (reason: HighlightFailureReason) => void;
77161
},
78162
): Promise<Map<string, HighlightResult> | null> {
79163
if (pages.length === 0) {
@@ -85,12 +169,16 @@ export async function generateHighlightsBatch(
85169
const start = Date.now();
86170
try {
87171
const baseUrl = config.HIGHLIGHT_MODEL_URL!.replace(/\/$/, "");
88-
const res = await fetch(`${baseUrl}/batch_highlight`, {
89-
method: "POST",
90-
headers: requestHeaders(opts.requestId),
91-
body: JSON.stringify({ query, pages }),
92-
signal: controller.signal,
93-
});
172+
const res = await fetchBatchWithRetry(
173+
`${baseUrl}/batch_highlight`,
174+
{
175+
method: "POST",
176+
headers: requestHeaders(opts.requestId),
177+
body: JSON.stringify({ query, pages }),
178+
signal: controller.signal,
179+
},
180+
controller.signal,
181+
);
94182

95183
if (!res.ok) {
96184
const body = await res.text().catch(() => "");
@@ -113,14 +201,23 @@ export async function generateHighlightsBatch(
113201
opts,
114202
);
115203
}
116-
throw new Error(
117-
`highlight model HTTP ${res.status}: ${body.slice(0, 200)}`,
118-
);
204+
throw new HighlightHttpError(res.status, body);
119205
}
120206

121-
const data = (await res.json()) as HighlightBatchResponse;
207+
const data: unknown = await res.json();
208+
if (
209+
typeof data !== "object" ||
210+
data === null ||
211+
Array.isArray(data) ||
212+
("pages" in data && !Array.isArray(data.pages))
213+
) {
214+
throw new HighlightInvalidResponseError(
215+
"highlight model returned an invalid response",
216+
);
217+
}
122218
const results = new Map<string, HighlightResult>();
123-
for (const page of data.pages ?? []) {
219+
for (const page of (data as HighlightBatchResponse).pages ?? []) {
220+
if (typeof page !== "object" || page === null) continue;
124221
if (typeof page.id !== "string" || !page.output) continue;
125222
results.set(page.id, {
126223
highlights: page.output.highlights ?? [],
@@ -145,6 +242,7 @@ export async function generateHighlightsBatch(
145242

146243
return results;
147244
} catch (error) {
245+
opts.onFailure?.(failureReason(error));
148246
opts.logger.warn("query highlights batch failed", {
149247
canonicalLog: "search/highlights",
150248
error: error instanceof Error ? error.message : String(error),

apps/api/src/search/highlights-shadow.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,33 @@ describe("runSearchHighlightsShadow", () => {
115115
await new Promise(resolve => setImmediate(resolve));
116116
});
117117

118+
it("emits the content-free failure category", async () => {
119+
vi.mocked(applySearchHighlights).mockResolvedValue({
120+
attempted: 3,
121+
indexHits: 1,
122+
replaced: 0,
123+
succeeded: false,
124+
failureReason: "network",
125+
});
126+
127+
runSearchHighlightsShadow({
128+
response: {} as any,
129+
query: "private query",
130+
requestId: "request-1",
131+
teamId: "team-1",
132+
zeroDataRetention: false,
133+
});
134+
await new Promise(resolve => setImmediate(resolve));
135+
136+
expect(logger.info).toHaveBeenCalledWith(
137+
"Search highlights shadow completed",
138+
expect.objectContaining({
139+
outcome: "failed",
140+
failureReason: "network",
141+
}),
142+
);
143+
});
144+
118145
it("does not shadow ZDR, disabled, or unavailable requests", () => {
119146
const input = {
120147
response: {} as any,

apps/api/src/search/highlights-shadow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function createSearchHighlightsShadowRunner(
7171
attempted: result.attempted,
7272
indexHits: result.indexHits,
7373
wouldReplace: result.replaced,
74+
failureReason: result.failureReason,
7475
timeTakenMs: Date.now() - startedAt,
7576
inFlight,
7677
maxInFlight: config.HIGHLIGHT_SHADOW_MAX_INFLIGHT,

apps/api/src/search/highlights.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ describe("applySearchHighlights", () => {
9595
markdown: "markdown:<main>index:https://second.test.json</main>",
9696
},
9797
],
98-
{ logger },
98+
{
99+
logger,
100+
onFailure: expect.any(Function),
101+
},
99102
);
100103
expect(response.web[0].description).toBe("first highlight");
101104
expect(response.news[0].snippet).toBe("second highlight");
@@ -184,6 +187,7 @@ describe("applySearchHighlights", () => {
184187
logger,
185188
logPayload: false,
186189
allowLegacyFallback: false,
190+
onFailure: expect.any(Function),
187191
},
188192
);
189193
expect(logger.info).not.toHaveBeenCalledWith(

apps/api/src/search/highlights.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { parseMarkdown } from "../lib/html-to-markdown";
1111
import { htmlTransform } from "../scraper/scrapeURL/lib/removeUnwantedElements";
1212
import type { ScrapeOptions } from "../controllers/v2/types";
1313
import { generateHighlightsBatch } from "./highlight-model";
14+
import type { HighlightFailureReason } from "./highlight-model";
1415
import { config } from "../config";
1516

1617
// How far back into the index we're willing to reach for highlight source text.
@@ -142,6 +143,7 @@ export async function applySearchHighlights(
142143
indexHits: number;
143144
replaced: number;
144145
succeeded: boolean;
146+
failureReason?: HighlightFailureReason;
145147
}> {
146148
const start = Date.now();
147149
const applyResults = options.applyResults ?? true;
@@ -194,6 +196,7 @@ export async function applySearchHighlights(
194196
// empty response only falls back the corresponding provider snippet.
195197
let replaced = 0;
196198
let succeeded = true;
199+
let failureReason: HighlightFailureReason | undefined;
197200
if (indexHits > 0) {
198201
const results = await generateHighlightsBatch(
199202
query,
@@ -206,9 +209,18 @@ export async function applySearchHighlights(
206209
logger,
207210
logPayload: !options.suppressPayloadLog,
208211
allowLegacyFallback: options.allowLegacyFallback,
209-
requestId: options.requestId,
212+
...(options.requestId ? { requestId: options.requestId } : {}),
213+
onFailure: reason => {
214+
failureReason = reason;
215+
},
210216
}
211-
: { logger, requestId: options.requestId },
217+
: {
218+
logger,
219+
...(options.requestId ? { requestId: options.requestId } : {}),
220+
onFailure: reason => {
221+
failureReason = reason;
222+
},
223+
},
212224
);
213225
succeeded = results !== null;
214226
if (results) {
@@ -238,5 +250,6 @@ export async function applySearchHighlights(
238250
indexHits,
239251
replaced,
240252
succeeded,
253+
...(failureReason ? { failureReason } : {}),
241254
};
242255
}

0 commit comments

Comments
 (0)