system HF Staff commited on
Commit
ccc76d6
ยท
verified ยท
1 Parent(s): 97d9c72

Sync from felladrin/MiniSearch@f8cb3e1 (part 2)

Browse files
server/fallbackSearchService.test.ts ADDED
@@ -0,0 +1,835 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import debug from "debug";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ type MockedFunction,
9
+ vi,
10
+ } from "vitest";
11
+ import {
12
+ fetchFallbackTextResults,
13
+ isSearchFallbackEnabled,
14
+ } from "./fallbackSearchService";
15
+
16
+ function createMockResponse(
17
+ body: string,
18
+ {
19
+ status,
20
+ ok = status === undefined ? true : status >= 200 && status < 300,
21
+ headers = {},
22
+ contentType = "application/json",
23
+ unreadBody = null,
24
+ }: {
25
+ ok?: boolean;
26
+ status?: number;
27
+ headers?: Record<string, string>;
28
+ contentType?: string;
29
+ unreadBody?: { cancel: () => Promise<void> } | null;
30
+ } = {},
31
+ ): Response {
32
+ const resolvedStatus = status ?? (ok ? 200 : 503);
33
+ return {
34
+ ok,
35
+ status: resolvedStatus,
36
+ statusText: ok ? "OK" : "Error",
37
+ headers: new Headers({ "content-type": contentType, ...headers }),
38
+ redirected: false,
39
+ type: "basic" as ResponseType,
40
+ url: "http://test.com",
41
+ clone: function () {
42
+ return this;
43
+ },
44
+ body: unreadBody,
45
+ bodyUsed: false,
46
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
47
+ blob: () => Promise.resolve(new Blob()),
48
+ formData: () => Promise.resolve(new FormData()),
49
+ json: () => Promise.resolve(JSON.parse(body)),
50
+ text: () => Promise.resolve(body),
51
+ } as unknown as Response;
52
+ }
53
+
54
+ const SESSION_ID = "session-from-step-one";
55
+ const QUERY = "borogoves outgrabe mimsy-42";
56
+ // The module's own cap on the exchange; every test that is not about the
57
+ // caller's budget passes it as the budget.
58
+ const FULL_BUDGET_MS = 15_000;
59
+
60
+ const INITIALIZE_REPLY = JSON.stringify({
61
+ jsonrpc: "2.0",
62
+ id: 1,
63
+ result: { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} },
64
+ });
65
+
66
+ const NOTIFICATION_ACCEPTED = "";
67
+
68
+ function toolReply(document: unknown): string {
69
+ return JSON.stringify({
70
+ jsonrpc: "2.0",
71
+ id: 2,
72
+ result: {
73
+ content: [{ type: "text", text: JSON.stringify(document) }],
74
+ },
75
+ });
76
+ }
77
+
78
+ function sseReply(message: string): string {
79
+ return `event: message\ndata: ${message}\n\n`;
80
+ }
81
+
82
+ function documentWith(results: unknown[]): string {
83
+ return toolReply({ search_id: "search-1", results });
84
+ }
85
+
86
+ const EXCERPT =
87
+ "The opening sentence of a very long excerpt that has to survive the filter. alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec";
88
+
89
+ const LONG_EXCERPT = `${EXCERPT} romeo sierra tango uniform victor whiskey xray yankee zulu alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu`;
90
+
91
+ let originalFetch: typeof fetch;
92
+ let fetchMock: MockedFunction<typeof fetch>;
93
+
94
+ const originalEnabled = process.env.SEARCH_FALLBACK_ENABLED;
95
+ const originalApiKey = process.env.SEARCH_FALLBACK_API_KEY;
96
+
97
+ beforeEach(() => {
98
+ delete process.env.SEARCH_FALLBACK_ENABLED;
99
+ delete process.env.SEARCH_FALLBACK_API_KEY;
100
+ originalFetch = global.fetch;
101
+ fetchMock = vi.fn() as unknown as MockedFunction<typeof fetch>;
102
+ global.fetch = fetchMock;
103
+ });
104
+
105
+ afterEach(() => {
106
+ global.fetch = originalFetch;
107
+ if (originalEnabled === undefined) delete process.env.SEARCH_FALLBACK_ENABLED;
108
+ else process.env.SEARCH_FALLBACK_ENABLED = originalEnabled;
109
+ if (originalApiKey === undefined) delete process.env.SEARCH_FALLBACK_API_KEY;
110
+ else process.env.SEARCH_FALLBACK_API_KEY = originalApiKey;
111
+ vi.restoreAllMocks();
112
+ });
113
+
114
+ /** Answers the three requests in order with the given tool-call reply. */
115
+ function mockExchange(
116
+ toolBody: string,
117
+ {
118
+ initializeBody = INITIALIZE_REPLY,
119
+ initializeContentType = "application/json",
120
+ toolContentType = "application/json",
121
+ }: {
122
+ initializeBody?: string;
123
+ initializeContentType?: string;
124
+ toolContentType?: string;
125
+ } = {},
126
+ ) {
127
+ fetchMock
128
+ .mockResolvedValueOnce(
129
+ createMockResponse(initializeBody, {
130
+ contentType: initializeContentType,
131
+ headers: { "mcp-session-id": SESSION_ID },
132
+ }),
133
+ )
134
+ .mockResolvedValueOnce(
135
+ createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
136
+ )
137
+ .mockResolvedValueOnce(
138
+ createMockResponse(toolBody, { contentType: toolContentType }),
139
+ );
140
+ }
141
+
142
+ function requestBody(index: number): {
143
+ jsonrpc?: string;
144
+ id?: number;
145
+ method?: string;
146
+ params?: {
147
+ name?: string;
148
+ arguments?: Record<string, unknown>;
149
+ protocolVersion?: string;
150
+ capabilities?: unknown;
151
+ clientInfo?: unknown;
152
+ };
153
+ } {
154
+ const [, init] = fetchMock.mock.calls[index];
155
+ return JSON.parse(String(init?.body));
156
+ }
157
+
158
+ function requestHeaders(index: number): Record<string, string> {
159
+ const [, init] = fetchMock.mock.calls[index];
160
+ return init?.headers as Record<string, string>;
161
+ }
162
+
163
+ describe("isSearchFallbackEnabled", () => {
164
+ it("is off when the variable is unset", () => {
165
+ expect(isSearchFallbackEnabled()).toBe(false);
166
+ });
167
+
168
+ it("accepts 'true' and ' TRUE ' and '1'", () => {
169
+ process.env.SEARCH_FALLBACK_ENABLED = "true";
170
+ expect(isSearchFallbackEnabled()).toBe(true);
171
+
172
+ process.env.SEARCH_FALLBACK_ENABLED = " TRUE ";
173
+ expect(isSearchFallbackEnabled()).toBe(true);
174
+
175
+ process.env.SEARCH_FALLBACK_ENABLED = "1";
176
+ expect(isSearchFallbackEnabled()).toBe(true);
177
+ });
178
+
179
+ it("rejects a value that is not true or 1", () => {
180
+ process.env.SEARCH_FALLBACK_ENABLED = "yes";
181
+ expect(isSearchFallbackEnabled()).toBe(false);
182
+ });
183
+
184
+ it("reads the variable at call time", () => {
185
+ expect(isSearchFallbackEnabled()).toBe(false);
186
+ process.env.SEARCH_FALLBACK_ENABLED = "true";
187
+ expect(isSearchFallbackEnabled()).toBe(true);
188
+ });
189
+ });
190
+
191
+ describe("the three requests", () => {
192
+ it("runs initialize, the initialized notification and the tool call in order", async () => {
193
+ mockExchange(documentWith([]));
194
+
195
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
196
+
197
+ expect(fetchMock).toHaveBeenCalledTimes(3);
198
+ expect(requestBody(0)).toEqual({
199
+ jsonrpc: "2.0",
200
+ id: 1,
201
+ method: "initialize",
202
+ params: {
203
+ protocolVersion: "2025-06-18",
204
+ capabilities: {},
205
+ clientInfo: { name: "MiniSearch", version: "1" },
206
+ },
207
+ });
208
+ expect(requestBody(1)).toEqual({
209
+ jsonrpc: "2.0",
210
+ method: "notifications/initialized",
211
+ });
212
+ expect(requestBody(2).method).toBe("tools/call");
213
+ expect(requestBody(2).params?.name).toBe("web_search");
214
+ expect(requestBody(2).params?.arguments).toEqual({
215
+ objective: QUERY,
216
+ search_queries: [QUERY],
217
+ session_id: expect.any(String),
218
+ });
219
+ });
220
+
221
+ it("carries the session id from step 1 onto steps 2 and 3, with the protocol version", async () => {
222
+ mockExchange(documentWith([]));
223
+
224
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
225
+
226
+ expect(requestHeaders(0)["Mcp-Session-Id"]).toBeUndefined();
227
+ expect(requestHeaders(0)["MCP-Protocol-Version"]).toBeUndefined();
228
+
229
+ for (const index of [1, 2]) {
230
+ expect(requestHeaders(index)["Mcp-Session-Id"]).toBe(SESSION_ID);
231
+ expect(requestHeaders(index)["MCP-Protocol-Version"]).toBe("2025-06-18");
232
+ }
233
+ });
234
+
235
+ it("sends one session id for the process, not one per search", async () => {
236
+ mockExchange(documentWith([]));
237
+ mockExchange(documentWith([]));
238
+
239
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
240
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
241
+
242
+ const first = requestBody(2).params?.arguments?.session_id;
243
+ const second = requestBody(5).params?.arguments?.session_id;
244
+ expect(typeof first).toBe("string");
245
+ expect(second).toBe(first);
246
+ });
247
+
248
+ it("sends the content type and accept header on every request", async () => {
249
+ mockExchange(documentWith([]));
250
+
251
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
252
+
253
+ for (let index = 0; index < 3; index++) {
254
+ const headers = requestHeaders(index);
255
+ expect(headers["Content-Type"]).toBe("application/json");
256
+ expect(headers.Accept).toBe("application/json, text/event-stream");
257
+ }
258
+ });
259
+
260
+ it("never sends a model name", async () => {
261
+ mockExchange(documentWith([]));
262
+
263
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
264
+
265
+ const toolCall = JSON.stringify(fetchMock.mock.calls[2][1]?.body);
266
+ expect(toolCall).not.toContain("model_name");
267
+ expect(Object.keys(requestBody(2).params?.arguments ?? {})).toEqual([
268
+ "objective",
269
+ "search_queries",
270
+ "session_id",
271
+ ]);
272
+ });
273
+
274
+ it("bounds all three requests with one abort signal", async () => {
275
+ mockExchange(documentWith([]));
276
+
277
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
278
+
279
+ const signals = fetchMock.mock.calls.map(([, init]) => init?.signal);
280
+ expect(signals[0]).toBeInstanceOf(AbortSignal);
281
+ expect(signals[1]).toBe(signals[0]);
282
+ expect(signals[2]).toBe(signals[0]);
283
+ });
284
+
285
+ it("hands the exchange the caller's budget, capped at its own", async () => {
286
+ mockExchange(documentWith([]));
287
+ mockExchange(documentWith([]));
288
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
289
+
290
+ await fetchFallbackTextResults(QUERY, 10, 3_000);
291
+ await fetchFallbackTextResults(QUERY, 10, 20_000);
292
+
293
+ expect(timeoutSpy).toHaveBeenNthCalledWith(1, 3_000);
294
+ // A budget past the module's own 15 s is not passed on: the exchange still
295
+ // ends at the cap, so the caller cannot lend it more time than it allows.
296
+ // Restored by the afterEach below, along with every other mock.
297
+ expect(timeoutSpy).toHaveBeenNthCalledWith(2, 15_000);
298
+ });
299
+
300
+ it("discards the unread bodies of the first two responses", async () => {
301
+ const cancelInitialize = vi.fn().mockResolvedValue(undefined);
302
+ const cancelInitialized = vi.fn().mockResolvedValue(undefined);
303
+ fetchMock
304
+ .mockResolvedValueOnce(
305
+ createMockResponse(INITIALIZE_REPLY, {
306
+ headers: { "mcp-session-id": SESSION_ID },
307
+ unreadBody: { cancel: cancelInitialize },
308
+ }),
309
+ )
310
+ .mockResolvedValueOnce(
311
+ createMockResponse(NOTIFICATION_ACCEPTED, {
312
+ status: 202,
313
+ unreadBody: { cancel: cancelInitialized },
314
+ }),
315
+ )
316
+ .mockResolvedValueOnce(createMockResponse(documentWith([])));
317
+
318
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
319
+
320
+ // An unread body keeps its socket out of the connection pool until it is
321
+ // collected, so the two replies nobody reads have to be dropped.
322
+ expect(cancelInitialize).toHaveBeenCalledTimes(1);
323
+ expect(cancelInitialized).toHaveBeenCalledTimes(1);
324
+ });
325
+
326
+ it("discards an unread body before throwing on a rejected initialize", async () => {
327
+ const cancelInitialize = vi.fn().mockResolvedValue(undefined);
328
+ fetchMock.mockResolvedValueOnce(
329
+ createMockResponse("", {
330
+ status: 500,
331
+ unreadBody: { cancel: cancelInitialize },
332
+ }),
333
+ );
334
+
335
+ await expect(
336
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
337
+ ).rejects.toThrow("did not open a session (status 500)");
338
+ expect(cancelInitialize).toHaveBeenCalledTimes(1);
339
+ });
340
+
341
+ it("omits the Bearer header when no key is configured", async () => {
342
+ mockExchange(documentWith([]));
343
+
344
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
345
+
346
+ for (let index = 0; index < 3; index++) {
347
+ expect(requestHeaders(index).Authorization).toBeUndefined();
348
+ }
349
+ });
350
+
351
+ it("sends the Bearer header on every request when a key is configured", async () => {
352
+ process.env.SEARCH_FALLBACK_API_KEY = "sk-fallback-key";
353
+ mockExchange(documentWith([]));
354
+
355
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
356
+
357
+ for (let index = 0; index < 3; index++) {
358
+ expect(requestHeaders(index).Authorization).toBe(
359
+ "Bearer sk-fallback-key",
360
+ );
361
+ }
362
+ });
363
+
364
+ it("treats a whitespace-only key as no key", async () => {
365
+ process.env.SEARCH_FALLBACK_API_KEY = " ";
366
+ mockExchange(documentWith([]));
367
+
368
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
369
+
370
+ expect(requestHeaders(0).Authorization).toBeUndefined();
371
+ });
372
+ });
373
+
374
+ describe("reading the reply", () => {
375
+ it("parses an application/json reply", async () => {
376
+ mockExchange(
377
+ toolReply({
378
+ search_id: "search-1",
379
+ results: [
380
+ {
381
+ url: "https://example.com/json",
382
+ title: "JSON title",
383
+ excerpts: [
384
+ `${"json ".repeat(12).trim()} an excerpt long enough to be kept by the length filter.`,
385
+ ],
386
+ },
387
+ ],
388
+ }),
389
+ );
390
+
391
+ const results = await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
392
+
393
+ expect(results).toHaveLength(1);
394
+ expect(results[0][0]).toBe("JSON title");
395
+ expect(results[0][2]).toBe("https://example.com/json");
396
+ });
397
+
398
+ it("parses a text/event-stream reply and skips blocks that are not JSON", async () => {
399
+ mockExchange(
400
+ `: keep-alive\n\n${sseReply(
401
+ JSON.stringify({
402
+ jsonrpc: "2.0",
403
+ id: 2,
404
+ result: {
405
+ content: [{ type: "text", text: JSON.stringify({ results: [] }) }],
406
+ },
407
+ }),
408
+ )}event: ping\ndata: not json\n\n`,
409
+ { toolContentType: "text/event-stream" },
410
+ );
411
+
412
+ await expect(
413
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
414
+ ).resolves.toEqual([]);
415
+ });
416
+
417
+ it("parses an SSE initialize reply", async () => {
418
+ mockExchange(documentWith([]), {
419
+ initializeBody: sseReply(INITIALIZE_REPLY),
420
+ initializeContentType: "text/event-stream",
421
+ });
422
+
423
+ await expect(
424
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
425
+ ).resolves.toEqual([]);
426
+ });
427
+
428
+ it("returns an empty array when nothing usable comes back", async () => {
429
+ mockExchange(documentWith([]));
430
+
431
+ await expect(
432
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
433
+ ).resolves.toEqual([]);
434
+ });
435
+ });
436
+
437
+ describe("mapping a result", () => {
438
+ const run = async (results: unknown[], limit = 10) => {
439
+ mockExchange(documentWith(results));
440
+ return fetchFallbackTextResults(QUERY, limit, FULL_BUDGET_MS);
441
+ };
442
+
443
+ it("drops navigation crumbs and keeps the paragraph", async () => {
444
+ const results = await run([
445
+ {
446
+ url: "https://example.com/guide",
447
+ title: "Guide",
448
+ excerpts: [
449
+ "Skip to main content",
450
+ "Home > Docs > Search > Guide",
451
+ EXCERPT,
452
+ ],
453
+ },
454
+ ]);
455
+
456
+ expect(results).toEqual([["Guide", EXCERPT, "https://example.com/guide"]]);
457
+ });
458
+
459
+ it("strips leading markdown markers", async () => {
460
+ const results = await run([
461
+ {
462
+ url: "https://example.com/markers",
463
+ title: "Markers",
464
+ excerpts: [`# ${EXCERPT}`],
465
+ },
466
+ ]);
467
+
468
+ expect(results[0][1]).toBe(EXCERPT);
469
+ });
470
+
471
+ it("reduces a markdown link to its text", async () => {
472
+ const results = await run([
473
+ {
474
+ url: "https://example.com/link",
475
+ title: "Link",
476
+ excerpts: [
477
+ `See the [reference page](https://example.com/reference) and then ${EXCERPT}`,
478
+ ],
479
+ },
480
+ ]);
481
+
482
+ expect(results[0][1]).toBe(`See the reference page and then ${EXCERPT}`);
483
+ });
484
+
485
+ it("joins excerpts and collapses the whitespace between them", async () => {
486
+ const first = `The first excerpt paragraph is long enough to be kept intact.`;
487
+ const second = `The second excerpt paragraph is long enough to be kept intact.`;
488
+ const results = await run([
489
+ {
490
+ url: "https://example.com/join",
491
+ title: "Join",
492
+ excerpts: [`${first}\n\n${second}`, "Short"],
493
+ },
494
+ ]);
495
+
496
+ expect(results[0][1]).toBe(`${first} ${second}`);
497
+ });
498
+
499
+ it("cuts at 400 characters, at a word boundary, and marks the cut", async () => {
500
+ expect(LONG_EXCERPT.length).toBeGreaterThan(400);
501
+
502
+ const results = await run([
503
+ {
504
+ url: "https://example.com/long",
505
+ title: "Long",
506
+ excerpts: [LONG_EXCERPT],
507
+ },
508
+ ]);
509
+
510
+ const snippet = results[0][1];
511
+ expect(snippet.endsWith("โ€ฆ")).toBe(true);
512
+ expect(snippet.length).toBeLessThanOrEqual(400);
513
+ const kept = snippet.slice(0, -1);
514
+ // The kept text is a prefix of the source, and the source continues with a
515
+ // space, so the cut landed between two words rather than inside one.
516
+ expect(LONG_EXCERPT.startsWith(kept)).toBe(true);
517
+ expect(LONG_EXCERPT[kept.length]).toBe(" ");
518
+ });
519
+
520
+ it("leaves a snippet that fits untouched", async () => {
521
+ expect(EXCERPT.length).toBeLessThanOrEqual(400);
522
+
523
+ const results = await run([
524
+ { url: "https://example.com/short", title: "Short", excerpts: [EXCERPT] },
525
+ ]);
526
+
527
+ expect(results[0][1]).toBe(EXCERPT);
528
+ expect(results[0][1]).not.toContain("โ€ฆ");
529
+ });
530
+
531
+ it("drops a result with no url, a null or empty title, or an empty snippet", async () => {
532
+ const results = await run([
533
+ { title: "No url", excerpts: [EXCERPT] },
534
+ {
535
+ url: "https://example.com/null-title",
536
+ title: null,
537
+ excerpts: [EXCERPT],
538
+ },
539
+ {
540
+ url: "https://example.com/empty-title",
541
+ title: " ",
542
+ excerpts: [EXCERPT],
543
+ },
544
+ {
545
+ url: "https://example.com/no-snippet",
546
+ title: "No snippet",
547
+ excerpts: [],
548
+ },
549
+ {
550
+ url: "https://example.com/crumb-only",
551
+ title: "Crumb only",
552
+ excerpts: ["Skip to main content"],
553
+ },
554
+ { url: "https://example.com/kept", title: "Kept", excerpts: [EXCERPT] },
555
+ ]);
556
+
557
+ expect(results).toEqual([["Kept", EXCERPT, "https://example.com/kept"]]);
558
+ });
559
+
560
+ it("drops a result whose url or title is not a string", async () => {
561
+ const results = await run([
562
+ { url: 42, title: "Number url", excerpts: [EXCERPT] },
563
+ { url: null, title: "Null url", excerpts: [EXCERPT] },
564
+ {
565
+ url: "https://example.com/number-title",
566
+ title: 7,
567
+ excerpts: [EXCERPT],
568
+ },
569
+ null,
570
+ { url: "https://example.com/kept", title: "Kept", excerpts: [EXCERPT] },
571
+ ]);
572
+
573
+ // One malformed entry must not take the rest of the search down with it.
574
+ expect(results).toEqual([["Kept", EXCERPT, "https://example.com/kept"]]);
575
+ });
576
+
577
+ it("drops duplicate urls in the endpoint's order", async () => {
578
+ const results = await run([
579
+ { url: "https://example.com/dup", title: "First", excerpts: [EXCERPT] },
580
+ {
581
+ url: "https://example.com/other",
582
+ title: "Second",
583
+ excerpts: [EXCERPT],
584
+ },
585
+ { url: "https://example.com/dup", title: "Third", excerpts: [EXCERPT] },
586
+ ]);
587
+
588
+ expect(results.map(([title]) => title)).toEqual(["First", "Second"]);
589
+ });
590
+
591
+ it("keeps the endpoint's relevance order", async () => {
592
+ const results = await run([
593
+ { url: "https://example.com/1", title: "One", excerpts: [EXCERPT] },
594
+ { url: "https://example.com/2", title: "Two", excerpts: [EXCERPT] },
595
+ { url: "https://example.com/3", title: "Three", excerpts: [EXCERPT] },
596
+ ]);
597
+
598
+ expect(results.map(([title]) => title)).toEqual(["One", "Two", "Three"]);
599
+ });
600
+
601
+ it("applies the limit", async () => {
602
+ const results = await run(
603
+ [
604
+ { url: "https://example.com/1", title: "One", excerpts: [EXCERPT] },
605
+ { url: "https://example.com/2", title: "Two", excerpts: [EXCERPT] },
606
+ { url: "https://example.com/3", title: "Three", excerpts: [EXCERPT] },
607
+ ],
608
+ 2,
609
+ );
610
+
611
+ expect(results.map(([title]) => title)).toEqual(["One", "Two"]);
612
+ });
613
+ });
614
+
615
+ describe("failures", () => {
616
+ it("throws when the tool call is not 2xx", async () => {
617
+ const cancel = vi.fn(async () => undefined);
618
+ fetchMock.mockResolvedValueOnce(
619
+ createMockResponse(INITIALIZE_REPLY, {
620
+ headers: { "mcp-session-id": SESSION_ID },
621
+ }),
622
+ );
623
+ fetchMock.mockResolvedValueOnce(
624
+ createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
625
+ );
626
+ fetchMock.mockResolvedValueOnce(
627
+ createMockResponse("bad", { status: 502, unreadBody: { cancel } }),
628
+ );
629
+
630
+ await expect(
631
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
632
+ ).rejects.toThrow("The fallback search tool call failed with status 502");
633
+ // An unread body keeps undici's socket out of the pool until it is
634
+ // garbage-collected, so the non-2xx path has to discard it like the two
635
+ // paths above it; dropping that line fails here.
636
+ expect(cancel).toHaveBeenCalledTimes(1);
637
+ });
638
+
639
+ it("throws when initialize does not open a session", async () => {
640
+ fetchMock.mockResolvedValueOnce(createMockResponse(INITIALIZE_REPLY));
641
+
642
+ await expect(
643
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
644
+ ).rejects.toThrow("did not open a session (status 200)");
645
+ expect(fetchMock).toHaveBeenCalledTimes(1);
646
+ });
647
+
648
+ it("throws on a JSON-RPC error and keeps the endpoint's message out of it", async () => {
649
+ fetchMock.mockResolvedValueOnce(
650
+ createMockResponse(INITIALIZE_REPLY, {
651
+ headers: { "mcp-session-id": SESSION_ID },
652
+ }),
653
+ );
654
+ fetchMock.mockResolvedValueOnce(
655
+ createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
656
+ );
657
+ fetchMock.mockResolvedValueOnce(
658
+ createMockResponse(
659
+ JSON.stringify({
660
+ jsonrpc: "2.0",
661
+ id: 2,
662
+ error: { code: -32601, message: "SECRET UPSTREAM TEXT" },
663
+ }),
664
+ ),
665
+ );
666
+
667
+ const rejection = await fetchFallbackTextResults(
668
+ QUERY,
669
+ 10,
670
+ FULL_BUDGET_MS,
671
+ ).catch((error: unknown) => error);
672
+ // The whole message is fixed text plus the code: an upstream message is
673
+ // free-form text from a third party and must not travel any further.
674
+ expect(rejection).toBeInstanceOf(Error);
675
+ expect((rejection as Error).message).toBe(
676
+ "The fallback search endpoint returned a JSON-RPC error (code -32601)",
677
+ );
678
+ expect(String(rejection)).not.toContain("SECRET UPSTREAM TEXT");
679
+ });
680
+
681
+ it("keeps a non-numeric JSON-RPC code out of the message", async () => {
682
+ fetchMock.mockResolvedValueOnce(
683
+ createMockResponse(INITIALIZE_REPLY, {
684
+ headers: { "mcp-session-id": SESSION_ID },
685
+ }),
686
+ );
687
+ fetchMock.mockResolvedValueOnce(
688
+ createMockResponse(NOTIFICATION_ACCEPTED, { status: 202 }),
689
+ );
690
+ fetchMock.mockResolvedValueOnce(
691
+ createMockResponse(
692
+ JSON.stringify({
693
+ jsonrpc: "2.0",
694
+ id: 2,
695
+ error: { code: "SECRET", message: "SECRET UPSTREAM TEXT" },
696
+ }),
697
+ ),
698
+ );
699
+
700
+ const rejection = await fetchFallbackTextResults(
701
+ QUERY,
702
+ 10,
703
+ FULL_BUDGET_MS,
704
+ ).catch((error: unknown) => error);
705
+ // A code is upstream data like the message is, so only a number may be
706
+ // carried into the thrown error.
707
+ expect(rejection).toBeInstanceOf(Error);
708
+ expect((rejection as Error).message).toBe(
709
+ "The fallback search endpoint returned a JSON-RPC error",
710
+ );
711
+ expect(String(rejection)).not.toContain("SECRET");
712
+ });
713
+
714
+ it("throws when the result is marked as an error", async () => {
715
+ mockExchange(
716
+ JSON.stringify({
717
+ jsonrpc: "2.0",
718
+ id: 2,
719
+ result: {
720
+ isError: true,
721
+ content: [{ type: "text", text: "upstream refused" }],
722
+ },
723
+ }),
724
+ );
725
+
726
+ await expect(
727
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
728
+ ).rejects.toThrow("reported the search as failed");
729
+ });
730
+
731
+ it("throws when the tool output is not JSON", async () => {
732
+ mockExchange(
733
+ JSON.stringify({
734
+ jsonrpc: "2.0",
735
+ id: 2,
736
+ result: { content: [{ type: "text", text: "<html>gateway</html>" }] },
737
+ }),
738
+ );
739
+
740
+ await expect(
741
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
742
+ ).rejects.toThrow("tool output that is not JSON");
743
+ });
744
+
745
+ it("throws a fixed message when the reply itself is not JSON", async () => {
746
+ mockExchange("<html>gateway</html>");
747
+
748
+ const rejection = await fetchFallbackTextResults(
749
+ QUERY,
750
+ 10,
751
+ FULL_BUDGET_MS,
752
+ ).catch((error: unknown) => error);
753
+ // A raw SyntaxError quotes the offending body, which would put the
754
+ // upstream response into the thrown error and then into the log.
755
+ expect((rejection as Error).message).toBe(
756
+ "The fallback search endpoint sent a reply that is not JSON",
757
+ );
758
+ expect(String(rejection)).not.toContain("<html>");
759
+ });
760
+
761
+ it("throws when the document has no results array", async () => {
762
+ mockExchange(toolReply({ search_id: "search-1" }));
763
+
764
+ await expect(
765
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
766
+ ).rejects.toThrow("without a results array");
767
+ });
768
+
769
+ it("throws when the endpoint is unreachable", async () => {
770
+ fetchMock.mockRejectedValue(new Error("Network failure"));
771
+
772
+ await expect(
773
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
774
+ ).rejects.toThrow("Network failure");
775
+ });
776
+ });
777
+
778
+ describe("privacy", () => {
779
+ let logLines: string[];
780
+ let originalLog: typeof debug.log;
781
+
782
+ beforeEach(() => {
783
+ logLines = [];
784
+ originalLog = debug.log;
785
+ debug.log = (...args: unknown[]) => {
786
+ logLines.push(args.map(String).join(" "));
787
+ };
788
+ for (const method of ["log", "info", "warn", "error", "debug"] as const) {
789
+ vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
790
+ logLines.push(args.map(String).join(" "));
791
+ });
792
+ }
793
+ });
794
+
795
+ afterEach(() => {
796
+ debug.log = originalLog;
797
+ });
798
+
799
+ it("never writes the query, the API key or the response text to the log", async () => {
800
+ process.env.SEARCH_FALLBACK_API_KEY = "sk-fallback-key";
801
+ mockExchange(
802
+ documentWith([
803
+ {
804
+ url: "https://example.com/private",
805
+ title: "PRIVATE RESPONSE TITLE",
806
+ excerpts: [
807
+ `PRIVATE RESPONSE EXCERPT that is long enough to be kept by the length filter and returned to the caller.`,
808
+ ],
809
+ },
810
+ ]),
811
+ );
812
+
813
+ await fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS);
814
+
815
+ // Failure paths too: they carry a fixed line plus a status or an error
816
+ // code, never the payload.
817
+ fetchMock.mockResolvedValueOnce(createMockResponse("", { status: 500 }));
818
+ await expect(
819
+ fetchFallbackTextResults(QUERY, 10, FULL_BUDGET_MS),
820
+ ).rejects.toThrow();
821
+
822
+ const output = logLines.join("\n");
823
+ for (const form of [
824
+ QUERY,
825
+ encodeURIComponent(QUERY),
826
+ QUERY.replaceAll(" ", "+"),
827
+ "PRIVATE RESPONSE TITLE",
828
+ "PRIVATE RESPONSE EXCERPT",
829
+ "sk-fallback-key",
830
+ ]) {
831
+ expect(output).not.toContain(form);
832
+ }
833
+ expect(logLines.length).toBeGreaterThan(0);
834
+ });
835
+ });
server/fallbackSearchService.ts ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import debug from "debug";
4
+
5
+ const fileName = basename(import.meta.url);
6
+ const printMessage = debug(fileName);
7
+ printMessage.enabled = true;
8
+
9
+ const ENDPOINT_URL = "https://search.parallel.ai/mcp";
10
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
11
+ // Bounds the whole three-request exchange, not each request: a fallback that
12
+ // hangs longer than this keeps the 502 the operator already gets from SearXNG
13
+ // waiting for a second source that is only there to soften the outage.
14
+ const FALLBACK_TIMEOUT_MS = 15_000;
15
+ const MAX_SNIPPET_LENGTH = 400;
16
+ // Excerpts arrive with navigation crumbs ("Skip to main content", menu items)
17
+ // that are sentences of their own; a real excerpt paragraph never runs this
18
+ // short, so length is the cheapest way to drop them without a stoplist.
19
+ const MIN_SNIPPET_LINE_LENGTH = 40;
20
+
21
+ // One per server process: the endpoint uses it for free-tier rate limiting and
22
+ // one MiniSearch instance is one client, so a fresh one per request would shed
23
+ // the rate limit instead of sharing it.
24
+ const SESSION_ID = randomUUID();
25
+
26
+ interface JsonRpcMessage {
27
+ id?: number;
28
+ error?: { code?: unknown };
29
+ result?: {
30
+ isError?: boolean;
31
+ content?: { type?: string; text?: string }[];
32
+ };
33
+ }
34
+
35
+ interface FallbackSearchResult {
36
+ url?: string;
37
+ title?: string | null;
38
+ publish_date?: string | null;
39
+ excerpts?: string[];
40
+ }
41
+
42
+ interface FallbackSearchDocument {
43
+ results?: FallbackSearchResult[];
44
+ }
45
+
46
+ type TextualResult = [title: string, content: string, url: string];
47
+
48
+ /**
49
+ * Whether the operator opted into the second text-search source. Off unless
50
+ * `SEARCH_FALLBACK_ENABLED` is `true` or `1`, read at call time so tests can
51
+ * toggle it.
52
+ */
53
+ export function isSearchFallbackEnabled(): boolean {
54
+ const value = process.env.SEARCH_FALLBACK_ENABLED?.trim().toLowerCase();
55
+ return value === "true" || value === "1";
56
+ }
57
+
58
+ /**
59
+ * Runs a text search against the fallback endpoint when SearXNG has already
60
+ * failed, and returns the same `[title, snippet, url]` tuples the SearXNG path
61
+ * produces, so the caller can rank and render them unchanged.
62
+ *
63
+ * `timeoutMs` is the time the caller can still afford, measured from its own
64
+ * start: the exchange is bounded by this and by the module's own cap, whichever
65
+ * is shorter, so a search that has already spent most of its budget on SearXNG
66
+ * cannot hang on here past the point where the client has given up.
67
+ *
68
+ * Throws on any failure rather than returning what it managed to collect: the
69
+ * caller has to decide between a degraded answer and the 502 it would have sent
70
+ * anyway, and it has to be able to count the decision. An empty array is the
71
+ * one non-throwing outcome that means the endpoint answered and nothing usable
72
+ * came back.
73
+ */
74
+ export async function fetchFallbackTextResults(
75
+ query: string,
76
+ limit: number,
77
+ timeoutMs: number,
78
+ ): Promise<TextualResult[]> {
79
+ const apiKey = process.env.SEARCH_FALLBACK_API_KEY?.trim();
80
+ const signal = AbortSignal.timeout(Math.min(FALLBACK_TIMEOUT_MS, timeoutMs));
81
+
82
+ const headers: Record<string, string> = {
83
+ "Content-Type": "application/json",
84
+ Accept: "application/json, text/event-stream",
85
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
86
+ };
87
+
88
+ const initializeResponse = await fetch(ENDPOINT_URL, {
89
+ method: "POST",
90
+ headers,
91
+ body: JSON.stringify({
92
+ jsonrpc: "2.0",
93
+ id: 1,
94
+ method: "initialize",
95
+ params: {
96
+ protocolVersion: MCP_PROTOCOL_VERSION,
97
+ capabilities: {},
98
+ clientInfo: { name: "MiniSearch", version: "1" },
99
+ },
100
+ }),
101
+ signal,
102
+ });
103
+ const sessionId = initializeResponse.headers.get("mcp-session-id");
104
+ // An unread body keeps undici's socket out of the pool until it is GC'd, so
105
+ // both outcomes have to discard it before they part ways with the response.
106
+ await initializeResponse.body?.cancel();
107
+ if (!initializeResponse.ok || !sessionId) {
108
+ throw new Error(
109
+ `The fallback search endpoint did not open a session (status ${initializeResponse.status})`,
110
+ );
111
+ }
112
+
113
+ const sessionHeaders = {
114
+ "Mcp-Session-Id": sessionId,
115
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
116
+ };
117
+
118
+ // A notification, so there is no reply body to read: the session is only
119
+ // usable once the endpoint has acknowledged it, and `ok` is the whole
120
+ // acknowledgement.
121
+ const initializedResponse = await fetch(ENDPOINT_URL, {
122
+ method: "POST",
123
+ headers: { ...headers, ...sessionHeaders },
124
+ body: JSON.stringify({
125
+ jsonrpc: "2.0",
126
+ method: "notifications/initialized",
127
+ }),
128
+ signal,
129
+ });
130
+ await initializedResponse.body?.cancel();
131
+ if (!initializedResponse.ok) {
132
+ throw new Error(
133
+ `The fallback search endpoint rejected the initialized notification (status ${initializedResponse.status})`,
134
+ );
135
+ }
136
+
137
+ const callResponse = await fetch(ENDPOINT_URL, {
138
+ method: "POST",
139
+ headers: { ...headers, ...sessionHeaders },
140
+ body: JSON.stringify({
141
+ jsonrpc: "2.0",
142
+ id: 2,
143
+ method: "tools/call",
144
+ params: {
145
+ name: "web_search",
146
+ // No `model_name`: this is a search, not a completion, and the tool
147
+ // does not need a client that claims to be a model.
148
+ arguments: {
149
+ objective: query,
150
+ search_queries: [query],
151
+ session_id: SESSION_ID,
152
+ },
153
+ },
154
+ }),
155
+ signal,
156
+ });
157
+ if (!callResponse.ok) {
158
+ await callResponse.body?.cancel();
159
+ throw new Error(
160
+ `The fallback search tool call failed with status ${callResponse.status}`,
161
+ );
162
+ }
163
+
164
+ const document = await readSearchDocument(callResponse, 2);
165
+ const textualResults = mapResults(document, limit);
166
+ printMessage(
167
+ `Fallback search usable text results: ${textualResults.length} of ${document.results?.length ?? 0}.`,
168
+ );
169
+ return textualResults;
170
+ }
171
+
172
+ /**
173
+ * Reads the JSON-RPC reply to `requestId`. The endpoint answers either one
174
+ * JSON message or an SSE stream of them, and which of them belongs to this
175
+ * request is decided by the id, not by position: the stream can carry
176
+ * notifications the client never asked for.
177
+ */
178
+ async function readSearchDocument(
179
+ response: Response,
180
+ requestId: number,
181
+ ): Promise<FallbackSearchDocument> {
182
+ const contentType = response.headers.get("content-type") ?? "";
183
+ const body = await response.text();
184
+
185
+ const messages = contentType.includes("text/event-stream")
186
+ ? parseEventStream(body)
187
+ : parseJsonBody(body);
188
+
189
+ const reply = messages.find((message) => message.id === requestId);
190
+ if (!reply) {
191
+ throw new Error(
192
+ `The fallback search endpoint sent no reply to request ${requestId}`,
193
+ );
194
+ }
195
+
196
+ // Only a numeric code: an upstream error message is free-form text from a
197
+ // third party, and a non-numeric code is upstream data just the same, so
198
+ // neither may reach the log or a thrown error.
199
+ if (reply.error) {
200
+ throw new Error(
201
+ typeof reply.error.code === "number"
202
+ ? `The fallback search endpoint returned a JSON-RPC error (code ${reply.error.code})`
203
+ : "The fallback search endpoint returned a JSON-RPC error",
204
+ );
205
+ }
206
+
207
+ if (reply.result?.isError) {
208
+ throw new Error(
209
+ "The fallback search endpoint reported the search as failed",
210
+ );
211
+ }
212
+
213
+ const payload = (reply.result?.content ?? [])
214
+ .flatMap((item) =>
215
+ item.type === "text" && typeof item.text === "string" ? [item.text] : [],
216
+ )
217
+ .join("\n");
218
+
219
+ try {
220
+ const parsed: unknown = JSON.parse(payload);
221
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
222
+ throw new Error();
223
+ }
224
+ return parsed as FallbackSearchDocument;
225
+ } catch {
226
+ throw new Error(
227
+ "The fallback search endpoint returned tool output that is not JSON",
228
+ );
229
+ }
230
+ }
231
+
232
+ function parseJsonBody(body: string): JsonRpcMessage[] {
233
+ let parsed: unknown;
234
+ try {
235
+ parsed = JSON.parse(body);
236
+ } catch {
237
+ // A raw SyntaxError quotes the offending snippet, which would carry the
238
+ // upstream response into the thrown error and from there into the log.
239
+ throw new Error(
240
+ "The fallback search endpoint sent a reply that is not JSON",
241
+ );
242
+ }
243
+
244
+ if (Array.isArray(parsed)) return parsed as JsonRpcMessage[];
245
+ return [parsed as JsonRpcMessage];
246
+ }
247
+
248
+ /**
249
+ * SSE blocks are separated by a blank line and each `data:` line holds a
250
+ * fragment of one JSON message. A block that is not JSON is a keep-alive or an
251
+ * event this client did not ask for, so it is skipped rather than failing a
252
+ * reply that may be perfectly good.
253
+ */
254
+ function parseEventStream(body: string): JsonRpcMessage[] {
255
+ return body.split(/\r?\n\r?\n/).flatMap((block) => {
256
+ const data = block
257
+ .split(/\r?\n/)
258
+ .filter((line) => line.startsWith("data:"))
259
+ .map((line) => line.slice("data:".length).trimStart())
260
+ .join("\n");
261
+
262
+ if (!data) return [];
263
+
264
+ try {
265
+ const parsed: unknown = JSON.parse(data);
266
+ if (Array.isArray(parsed)) return parsed as JsonRpcMessage[];
267
+ return [parsed as JsonRpcMessage];
268
+ } catch {
269
+ return [];
270
+ }
271
+ });
272
+ }
273
+
274
+ function mapResults(
275
+ document: FallbackSearchDocument,
276
+ limit: number,
277
+ ): TextualResult[] {
278
+ if (!Array.isArray(document.results)) {
279
+ throw new Error(
280
+ "The fallback search endpoint returned a document without a results array",
281
+ );
282
+ }
283
+
284
+ const seenUrls = new Set<string>();
285
+ const mapped: TextualResult[] = [];
286
+
287
+ for (const result of document.results) {
288
+ if (mapped.length >= limit) break;
289
+ // Upstream data, so one malformed entry must not fail the whole search: a
290
+ // result that is not an object, or whose url or title is not a string, is
291
+ // skipped rather than left to throw on `.trim()`.
292
+ if (
293
+ !result ||
294
+ typeof result !== "object" ||
295
+ typeof result.url !== "string" ||
296
+ !result.url ||
297
+ seenUrls.has(result.url)
298
+ ) {
299
+ continue;
300
+ }
301
+
302
+ const title = typeof result.title === "string" ? result.title.trim() : "";
303
+ if (!title) continue;
304
+
305
+ const snippet = buildSnippet(result.excerpts);
306
+ if (!snippet) continue;
307
+
308
+ seenUrls.add(result.url);
309
+ mapped.push([title, snippet, result.url]);
310
+ }
311
+
312
+ return mapped;
313
+ }
314
+
315
+ /**
316
+ * Turns the endpoint's markdown excerpts into the plain, single-line snippet
317
+ * the SearXNG path hands to ranking.
318
+ */
319
+ function buildSnippet(excerpts: string[] | undefined): string {
320
+ if (!Array.isArray(excerpts) || excerpts.length === 0) return "";
321
+
322
+ const snippet = excerpts
323
+ .join("\n")
324
+ .split("\n")
325
+ .map(stripMarkdown)
326
+ .filter((line) => line.length >= MIN_SNIPPET_LINE_LENGTH)
327
+ .join(" ")
328
+ .replace(/\s+/g, " ")
329
+ .trim();
330
+
331
+ if (snippet.length <= MAX_SNIPPET_LENGTH) return snippet;
332
+
333
+ // Cut at a word boundary: a mid-word stump reads as a rendering bug, and the
334
+ // ellipsis is what tells the reader the sentence was cut, not finished.
335
+ const cut = snippet.slice(0, MAX_SNIPPET_LENGTH);
336
+ const lastSpace = cut.lastIndexOf(" ");
337
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}โ€ฆ`;
338
+ }
339
+
340
+ function stripMarkdown(line: string): string {
341
+ return line
342
+ .replace(/^(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s+)/, "")
343
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
344
+ .trim();
345
+ }
server/searchEndpointServerHook.test.ts CHANGED
@@ -1,6 +1,11 @@
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
 
 
 
 
 
 
4
  vi.mock("./handleTokenVerification", () => ({
5
  handleTokenVerification: vi.fn(),
6
  }));
@@ -16,6 +21,8 @@ vi.mock("./rerankerService", () => ({
16
  vi.mock("./searchesSinceLastRestart", () => ({
17
  incrementTextualSearchesSinceLastRestart: vi.fn(),
18
  incrementGraphicalSearchesSinceLastRestart: vi.fn(),
 
 
19
  recordSearchDuration: vi.fn(),
20
  }));
21
 
@@ -23,12 +30,18 @@ vi.mock("./webSearchService", () => ({
23
  fetchSearXNG: vi.fn(),
24
  }));
25
 
 
 
 
 
26
  import { handleTokenVerification } from "./handleTokenVerification";
27
  import { rankSearchResults } from "./rankSearchResults";
28
  import { getRerankerStatus } from "./rerankerService";
29
  import { searchEndpointServerHook } from "./searchEndpointServerHook";
30
  import {
31
  incrementGraphicalSearchesSinceLastRestart,
 
 
32
  incrementTextualSearchesSinceLastRestart,
33
  recordSearchDuration,
34
  } from "./searchesSinceLastRestart";
@@ -75,6 +88,11 @@ describe("searchEndpointServerHook", () => {
75
  async (_query, _searchType, results) =>
76
  results.map(([title, content, url]) => [title, content, url, 0]),
77
  );
 
 
 
 
 
78
  });
79
 
80
  it("passes through requests that aren't under /search/", async () => {
@@ -425,6 +443,255 @@ describe("searchEndpointServerHook", () => {
425
  JSON.stringify({ error: "Search service unavailable" }),
426
  );
427
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  });
429
 
430
  it("responds 500 when an unexpected error is thrown", async () => {
 
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
 
4
+ vi.mock("./fallbackSearchService", () => ({
5
+ fetchFallbackTextResults: vi.fn(),
6
+ isSearchFallbackEnabled: vi.fn(),
7
+ }));
8
+
9
  vi.mock("./handleTokenVerification", () => ({
10
  handleTokenVerification: vi.fn(),
11
  }));
 
21
  vi.mock("./searchesSinceLastRestart", () => ({
22
  incrementTextualSearchesSinceLastRestart: vi.fn(),
23
  incrementGraphicalSearchesSinceLastRestart: vi.fn(),
24
+ incrementSearchesServedByFallback: vi.fn(),
25
+ incrementSearchesFailedOnFallback: vi.fn(),
26
  recordSearchDuration: vi.fn(),
27
  }));
28
 
 
30
  fetchSearXNG: vi.fn(),
31
  }));
32
 
33
+ import {
34
+ fetchFallbackTextResults,
35
+ isSearchFallbackEnabled,
36
+ } from "./fallbackSearchService";
37
  import { handleTokenVerification } from "./handleTokenVerification";
38
  import { rankSearchResults } from "./rankSearchResults";
39
  import { getRerankerStatus } from "./rerankerService";
40
  import { searchEndpointServerHook } from "./searchEndpointServerHook";
41
  import {
42
  incrementGraphicalSearchesSinceLastRestart,
43
+ incrementSearchesFailedOnFallback,
44
+ incrementSearchesServedByFallback,
45
  incrementTextualSearchesSinceLastRestart,
46
  recordSearchDuration,
47
  } from "./searchesSinceLastRestart";
 
88
  async (_query, _searchType, results) =>
89
  results.map(([title, content, url]) => [title, content, url, 0]),
90
  );
91
+ // Off unless a test switches it on, and answered with something rather
92
+ // than undefined so an unexpected call fails a visible assertion instead
93
+ // of poisoning the results below.
94
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(false);
95
+ vi.mocked(fetchFallbackTextResults).mockResolvedValue([]);
96
  });
97
 
98
  it("passes through requests that aren't under /search/", async () => {
 
443
  JSON.stringify({ error: "Search service unavailable" }),
444
  );
445
  });
446
+
447
+ describe("text search fallback", () => {
448
+ const fallbackResults: [string, string, string][] = [
449
+ ["Fallback title", "Fallback snippet", "https://example.com/fallback"],
450
+ ];
451
+ const rankedFallbackResults: [string, string, string, number][] = [
452
+ [
453
+ "Fallback title",
454
+ "Fallback snippet",
455
+ "https://example.com/fallback",
456
+ 0.7,
457
+ ],
458
+ ];
459
+
460
+ function failSearxng() {
461
+ vi.mocked(fetchSearXNG).mockRejectedValue(
462
+ new Error("SearXNG request failed with status 503"),
463
+ );
464
+ }
465
+
466
+ it("serves the fallback results through the normal text path", async () => {
467
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
468
+ failSearxng();
469
+ vi.mocked(fetchFallbackTextResults).mockResolvedValue(fallbackResults);
470
+ vi.mocked(getRerankerStatus).mockResolvedValue(true);
471
+ vi.mocked(rankSearchResults).mockResolvedValue(rankedFallbackResults);
472
+ // A search that has just started hands the fallback the whole budget.
473
+ vi.spyOn(performance, "now").mockReturnValue(0);
474
+
475
+ const handler = getRegisteredHandler();
476
+ const response = createResponse();
477
+
478
+ await handler(
479
+ createRequest("/search/text?q=cats&token=abc"),
480
+ response,
481
+ vi.fn(),
482
+ );
483
+
484
+ expect(fetchFallbackTextResults).toHaveBeenCalledWith(
485
+ "cats",
486
+ 30,
487
+ 25_000,
488
+ );
489
+ // Reranked exactly like SearXNG results, with the top ones preserved,
490
+ // and then answered by the one respond block the text path has.
491
+ expect(rankSearchResults).toHaveBeenCalledWith(
492
+ "cats",
493
+ "text",
494
+ fallbackResults,
495
+ true,
496
+ );
497
+ expect(incrementTextualSearchesSinceLastRestart).toHaveBeenCalledTimes(
498
+ 1,
499
+ );
500
+ expect(incrementSearchesServedByFallback).toHaveBeenCalledTimes(1);
501
+ expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
502
+ // The duration metric is SearXNG's; the fallback's own latency is a
503
+ // different thing and would be misleading under the same average.
504
+ expect(recordSearchDuration).not.toHaveBeenCalled();
505
+ expect(response.statusCode).toBe(200);
506
+ expect(response.end).toHaveBeenCalledWith(
507
+ JSON.stringify(rankedFallbackResults),
508
+ );
509
+ });
510
+
511
+ it("answers 200 with an empty list when the fallback returns nothing usable", async () => {
512
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
513
+ failSearxng();
514
+ vi.mocked(fetchFallbackTextResults).mockResolvedValue([]);
515
+
516
+ const handler = getRegisteredHandler();
517
+ const response = createResponse();
518
+
519
+ await handler(
520
+ createRequest("/search/text?q=cats&token=abc"),
521
+ response,
522
+ vi.fn(),
523
+ );
524
+
525
+ // The endpoint answered, so this is a search with no results rather
526
+ // than an outage: the client shows the no-results alert, not the
527
+ // unavailable one, and the fallback still counts as served.
528
+ expect(response.statusCode).toBe(200);
529
+ expect(response.end).toHaveBeenCalledWith(JSON.stringify([]));
530
+ expect(incrementSearchesServedByFallback).toHaveBeenCalledTimes(1);
531
+ expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
532
+ expect(recordSearchDuration).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it("skips the fallback when the search deadline has already passed", async () => {
536
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
537
+ failSearxng();
538
+ // 0 for the search's start, then past the 25 s deadline.
539
+ vi.spyOn(performance, "now")
540
+ .mockReturnValueOnce(0)
541
+ .mockReturnValue(25_001);
542
+
543
+ const handler = getRegisteredHandler();
544
+ const response = createResponse();
545
+
546
+ await handler(
547
+ createRequest("/search/text?q=cats&token=abc"),
548
+ response,
549
+ vi.fn(),
550
+ );
551
+
552
+ // A fallback started now would answer after the client's own 30 s
553
+ // timeout, so it is never asked and the outage is reported the way it
554
+ // would have been with the feature off.
555
+ expect(fetchFallbackTextResults).not.toHaveBeenCalled();
556
+ expect(response.statusCode).toBe(502);
557
+ expect(response.end).toHaveBeenCalledWith(
558
+ JSON.stringify({ error: "Search service unavailable" }),
559
+ );
560
+ expect(incrementSearchesFailedOnFallback).toHaveBeenCalledTimes(1);
561
+ expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
562
+ expect(recordSearchDuration).not.toHaveBeenCalled();
563
+
564
+ const logged = vi.mocked(console.error).mock.calls.flat().join(" ");
565
+ expect(logged).toContain(
566
+ "the search deadline left the fallback too little time",
567
+ );
568
+ expect(logged).not.toContain("cats");
569
+ });
570
+
571
+ it("hands the fallback the time left, not its own full budget", async () => {
572
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
573
+ failSearxng();
574
+ vi.mocked(fetchFallbackTextResults).mockResolvedValue([]);
575
+ // 0 for the search's start, then 12 s in when SearXNG gives up.
576
+ vi.spyOn(performance, "now")
577
+ .mockReturnValueOnce(0)
578
+ .mockReturnValue(12_000);
579
+
580
+ const handler = getRegisteredHandler();
581
+ const response = createResponse();
582
+
583
+ await handler(
584
+ createRequest("/search/text?q=cats&token=abc"),
585
+ response,
586
+ vi.fn(),
587
+ );
588
+
589
+ // 13 s are left of the 25 s deadline, which is under the fallback's
590
+ // own 15 s cap, so the search cannot outlive the client's patience.
591
+ expect(fetchFallbackTextResults).toHaveBeenCalledWith(
592
+ "cats",
593
+ 30,
594
+ 13_000,
595
+ );
596
+ expect(response.statusCode).toBe(200);
597
+ });
598
+
599
+ it("answers 502 when the fallback fails too", async () => {
600
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
601
+ failSearxng();
602
+ vi.mocked(fetchFallbackTextResults).mockRejectedValue(
603
+ new Error("The fallback search tool call failed with status 502"),
604
+ );
605
+
606
+ const handler = getRegisteredHandler();
607
+ const response = createResponse();
608
+
609
+ await handler(
610
+ createRequest("/search/text?q=cats&token=abc"),
611
+ response,
612
+ vi.fn(),
613
+ );
614
+
615
+ expect(response.statusCode).toBe(502);
616
+ expect(response.end).toHaveBeenCalledWith(
617
+ JSON.stringify({ error: "Search service unavailable" }),
618
+ );
619
+ expect(incrementSearchesFailedOnFallback).toHaveBeenCalledTimes(1);
620
+ expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
621
+ expect(recordSearchDuration).not.toHaveBeenCalled();
622
+
623
+ const logged = vi.mocked(console.error).mock.calls.flat().join(" ");
624
+ expect(logged).toContain(
625
+ "The fallback search tool call failed with status 502",
626
+ );
627
+ expect(logged).not.toContain("cats");
628
+ });
629
+
630
+ it("answers 502 without consulting the fallback when it is switched off", async () => {
631
+ failSearxng();
632
+
633
+ const handler = getRegisteredHandler();
634
+ const response = createResponse();
635
+
636
+ await handler(
637
+ createRequest("/search/text?q=cats&token=abc"),
638
+ response,
639
+ vi.fn(),
640
+ );
641
+
642
+ expect(response.statusCode).toBe(502);
643
+ expect(response.end).toHaveBeenCalledWith(
644
+ JSON.stringify({ error: "Search service unavailable" }),
645
+ );
646
+ expect(fetchFallbackTextResults).not.toHaveBeenCalled();
647
+ expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
648
+ expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
649
+ });
650
+
651
+ it("never consults the fallback for an image search", async () => {
652
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
653
+ failSearxng();
654
+
655
+ const handler = getRegisteredHandler();
656
+ const response = createResponse();
657
+
658
+ await handler(
659
+ createRequest("/search/images?q=cats&token=abc"),
660
+ response,
661
+ vi.fn(),
662
+ );
663
+
664
+ expect(response.statusCode).toBe(502);
665
+ expect(response.end).toHaveBeenCalledWith(
666
+ JSON.stringify({ error: "Search service unavailable" }),
667
+ );
668
+ expect(fetchFallbackTextResults).not.toHaveBeenCalled();
669
+ expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
670
+ expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
671
+ });
672
+
673
+ it("never consults the fallback when SearXNG answers", async () => {
674
+ vi.mocked(isSearchFallbackEnabled).mockReturnValue(true);
675
+ vi.mocked(fetchSearXNG).mockResolvedValue(searxngResults);
676
+
677
+ const handler = getRegisteredHandler();
678
+ const response = createResponse();
679
+
680
+ await handler(
681
+ createRequest("/search/text?q=cats&token=abc"),
682
+ response,
683
+ vi.fn(),
684
+ );
685
+
686
+ expect(response.statusCode).toBe(200);
687
+ expect(response.end).toHaveBeenCalledWith(
688
+ JSON.stringify(searxngResults),
689
+ );
690
+ expect(fetchFallbackTextResults).not.toHaveBeenCalled();
691
+ expect(incrementSearchesServedByFallback).not.toHaveBeenCalled();
692
+ expect(incrementSearchesFailedOnFallback).not.toHaveBeenCalled();
693
+ });
694
+ });
695
  });
696
 
697
  it("responds 500 when an unexpected error is thrown", async () => {
server/searchEndpointServerHook.ts CHANGED
@@ -1,5 +1,9 @@
1
  import type { PreviewServer, ViteDevServer } from "vite";
2
  import { z } from "zod";
 
 
 
 
3
  import { handleTokenVerification } from "./handleTokenVerification.ts";
4
  import { rankSearchResults } from "./rankSearchResults.ts";
5
  import { getRerankerStatus } from "./rerankerService.ts";
@@ -9,6 +13,8 @@ import {
9
  } from "./rerankingSinceLastRestart.ts";
10
  import {
11
  incrementGraphicalSearchesSinceLastRestart,
 
 
12
  incrementTextualSearchesSinceLastRestart,
13
  recordSearchDuration,
14
  type SearchType,
@@ -19,6 +25,14 @@ import { fetchSearXNG } from "./webSearchService.ts";
19
  const DEFAULT_SEARCH_LIMIT = 30;
20
  const MAX_SEARCH_LIMIT = 30;
21
  const MAX_QUERY_LENGTH = 2000;
 
 
 
 
 
 
 
 
22
 
23
  const searchParamsSchema = z.object({
24
  query: z
@@ -119,14 +133,61 @@ export function searchEndpointServerHook<
119
  const isTextSearch = request.url?.startsWith("/search/text");
120
  const searchType = isTextSearch ? "text" : "images";
121
 
122
- let searxngResults: TextResult[] | ImageResult[];
123
  const searchStartedAt = performance.now();
124
  try {
125
- searxngResults = await fetchSearXNG(query, searchType, limit);
126
  recordSearchDuration(searchType, performance.now() - searchStartedAt);
127
  } catch {
128
- // SearXNG is unreachable: answer non-200 so the client can tell an
129
- // outage apart from a search that genuinely has no results.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  response.statusCode = 502;
131
  response.setHeader("Content-Type", "application/json");
132
  response.end(JSON.stringify({ error: "Search service unavailable" }));
@@ -134,7 +195,7 @@ export function searchEndpointServerHook<
134
  }
135
 
136
  if (isTextSearch) {
137
- const results = searxngResults as TextResult[];
138
  const rankedResults = await handleRanking(
139
  query,
140
  searchType,
@@ -147,7 +208,7 @@ export function searchEndpointServerHook<
147
  response.setHeader("Content-Type", "application/json");
148
  response.end(JSON.stringify(rankedResults));
149
  } else {
150
- const results = searxngResults as ImageResult[];
151
  const resultsText = results.map(
152
  ([title, url]) => [title?.slice(0, 100) || "", "", url] as TextResult,
153
  );
 
1
  import type { PreviewServer, ViteDevServer } from "vite";
2
  import { z } from "zod";
3
+ import {
4
+ fetchFallbackTextResults,
5
+ isSearchFallbackEnabled,
6
+ } from "./fallbackSearchService.ts";
7
  import { handleTokenVerification } from "./handleTokenVerification.ts";
8
  import { rankSearchResults } from "./rankSearchResults.ts";
9
  import { getRerankerStatus } from "./rerankerService.ts";
 
13
  } from "./rerankingSinceLastRestart.ts";
14
  import {
15
  incrementGraphicalSearchesSinceLastRestart,
16
+ incrementSearchesFailedOnFallback,
17
+ incrementSearchesServedByFallback,
18
  incrementTextualSearchesSinceLastRestart,
19
  recordSearchDuration,
20
  type SearchType,
 
25
  const DEFAULT_SEARCH_LIMIT = 30;
26
  const MAX_SEARCH_LIMIT = 30;
27
  const MAX_QUERY_LENGTH = 2000;
28
+ // The client aborts a search after REQUEST_TIMEOUT in client/modules/search.ts
29
+ // (30 s) and shows its timeout alert, so a response still being assembled when
30
+ // that lands is a response the user never sees. The fallback may not push a
31
+ // search past this point; the 5 s margin is for ranking and the transfer.
32
+ const SEARCH_DEADLINE_MS = 25_000;
33
+ // A fallback cannot finish in this window, so starting one would only move the
34
+ // same 502 further out.
35
+ const MIN_FALLBACK_BUDGET_MS = 2_000;
36
 
37
  const searchParamsSchema = z.object({
38
  query: z
 
133
  const isTextSearch = request.url?.startsWith("/search/text");
134
  const searchType = isTextSearch ? "text" : "images";
135
 
136
+ let searchResults: TextResult[] | ImageResult[] | null;
137
  const searchStartedAt = performance.now();
138
  try {
139
+ searchResults = await fetchSearXNG(query, searchType, limit);
140
  recordSearchDuration(searchType, performance.now() - searchStartedAt);
141
  } catch {
142
+ // SearXNG is unreachable. The operator may have switched on a second
143
+ // text source, which is consulted only here, after SearXNG has failed,
144
+ // and never for images: Parallel has no image search.
145
+ searchResults = null;
146
+
147
+ if (isTextSearch && isSearchFallbackEnabled()) {
148
+ const remainingMs =
149
+ SEARCH_DEADLINE_MS - (performance.now() - searchStartedAt);
150
+
151
+ if (remainingMs < MIN_FALLBACK_BUDGET_MS) {
152
+ // The deadline leaves too little time for a second source, so it is
153
+ // not asked and the 502 goes out now. Counted as a fallback that
154
+ // failed rather than one that served, since none of its results
155
+ // could have reached the client in time.
156
+ incrementSearchesFailedOnFallback();
157
+ console.error(
158
+ "SearXNG failed, and the search deadline left the fallback too little time",
159
+ );
160
+ } else {
161
+ try {
162
+ searchResults = await fetchFallbackTextResults(
163
+ query,
164
+ limit,
165
+ remainingMs,
166
+ );
167
+ incrementSearchesServedByFallback();
168
+ console.log(
169
+ "SearXNG failed, served text results from the fallback search",
170
+ );
171
+ } catch (fallbackError) {
172
+ incrementSearchesFailedOnFallback();
173
+ // Fixed text plus the error's message, which by construction
174
+ // carries an HTTP status or a JSON-RPC code, never the query.
175
+ console.error(
176
+ `SearXNG failed, and the fallback search failed: ${
177
+ fallbackError instanceof Error
178
+ ? fallbackError.message
179
+ : String(fallbackError)
180
+ }`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ if (searchResults === null) {
188
+ // The fallback is off, is not offered for this search type, was skipped
189
+ // for lack of deadline, or failed itself: answer non-200 so the client
190
+ // can tell an outage apart from a search that genuinely has no results.
191
  response.statusCode = 502;
192
  response.setHeader("Content-Type", "application/json");
193
  response.end(JSON.stringify({ error: "Search service unavailable" }));
 
195
  }
196
 
197
  if (isTextSearch) {
198
+ const results = searchResults as TextResult[];
199
  const rankedResults = await handleRanking(
200
  query,
201
  searchType,
 
208
  response.setHeader("Content-Type", "application/json");
209
  response.end(JSON.stringify(rankedResults));
210
  } else {
211
+ const results = searchResults as ImageResult[];
212
  const resultsText = results.map(
213
  ([title, url]) => [title?.slice(0, 100) || "", "", url] as TextResult,
214
  );
server/searchesSinceLastRestart.ts CHANGED
@@ -53,6 +53,31 @@ export function incrementSearchesWithAllResultsDiscardedSinceLastRestart() {
53
  searchesWithAllResultsDiscardedSinceLastRestart++;
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  /** One engine SearXNG reported as unresponsive, with the reason it gave. */
57
  export interface UnresponsiveEngine {
58
  engine: string;
 
53
  searchesWithAllResultsDiscardedSinceLastRestart++;
54
  }
55
 
56
+ // The fallback is only consulted after SearXNG has already failed, so these two
57
+ // say whether the second source pulls its weight: served is a search the caller
58
+ // got anyway, failed is one that ended as the 502 there was before it existed,
59
+ // including a search whose SearXNG retries left the deadline too little time to
60
+ // ask the fallback at all. A search with the fallback off, or an image search,
61
+ // lands in neither.
62
+ let searchesServedByFallback = 0;
63
+ let searchesFailedOnFallback = 0;
64
+
65
+ export function getSearchesServedByFallback() {
66
+ return searchesServedByFallback;
67
+ }
68
+
69
+ export function incrementSearchesServedByFallback() {
70
+ searchesServedByFallback++;
71
+ }
72
+
73
+ export function getSearchesFailedOnFallback() {
74
+ return searchesFailedOnFallback;
75
+ }
76
+
77
+ export function incrementSearchesFailedOnFallback() {
78
+ searchesFailedOnFallback++;
79
+ }
80
+
81
  /** One engine SearXNG reported as unresponsive, with the reason it gave. */
82
  export interface UnresponsiveEngine {
83
  engine: string;
server/statusEndpointServerHook.test.ts CHANGED
@@ -1,5 +1,9 @@
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
  import { describe, expect, it, vi } from "vitest";
 
 
 
 
3
  import { statusEndpointServerHook } from "./statusEndpointServerHook.ts";
4
 
5
  // The health getters probe live services; this test is about the payload,
@@ -67,6 +71,26 @@ describe("statusEndpointServerHook", () => {
67
  });
68
  });
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  it("publishes the page-read circuit count beside the outcome counters", async () => {
71
  const status = await callStatus();
72
 
 
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
  import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ incrementSearchesFailedOnFallback,
5
+ incrementSearchesServedByFallback,
6
+ } from "./searchesSinceLastRestart.ts";
7
  import { statusEndpointServerHook } from "./statusEndpointServerHook.ts";
8
 
9
  // The health getters probe live services; this test is about the payload,
 
71
  });
72
  });
73
 
74
+ it("publishes the two fallback counters as plain counts", async () => {
75
+ const before = await callStatus();
76
+
77
+ incrementSearchesServedByFallback();
78
+ incrementSearchesFailedOnFallback();
79
+ incrementSearchesFailedOnFallback();
80
+
81
+ const status = await callStatus();
82
+
83
+ // Exact values rather than expect.any(Number), which a pair of swapped
84
+ // getters would satisfy too. The counters are module state that no test
85
+ // here resets, so the deltas are what each getter moved.
86
+ expect(status.searchesServedByFallback).toBe(
87
+ (before.searchesServedByFallback as number) + 1,
88
+ );
89
+ expect(status.searchesFailedOnFallback).toBe(
90
+ (before.searchesFailedOnFallback as number) + 2,
91
+ );
92
+ });
93
+
94
  it("publishes the page-read circuit count beside the outcome counters", async () => {
95
  const status = await callStatus();
96
 
server/statusEndpointServerHook.ts CHANGED
@@ -10,6 +10,8 @@ import { getRerankingStats } from "./rerankingSinceLastRestart.ts";
10
  import {
11
  getDegradedSearchTypes,
12
  getGraphicalSearchesSinceLastRestart,
 
 
13
  getSearchesWithAllResultsDiscardedSinceLastRestart,
14
  getSearchesWithoutResultsSinceLastRestart,
15
  getSearchesWithUnresponsiveEnginesSinceLastRestart,
@@ -83,6 +85,8 @@ export function statusEndpointServerHook<
83
  getSearchesWithUnresponsiveEnginesSinceLastRestart(),
84
  searchesWithAllResultsDiscarded:
85
  getSearchesWithAllResultsDiscardedSinceLastRestart(),
 
 
86
  biEncoderServiceStatus,
87
  rerankerServiceStatus,
88
  webSearchServiceStatus,
 
10
  import {
11
  getDegradedSearchTypes,
12
  getGraphicalSearchesSinceLastRestart,
13
+ getSearchesFailedOnFallback,
14
+ getSearchesServedByFallback,
15
  getSearchesWithAllResultsDiscardedSinceLastRestart,
16
  getSearchesWithoutResultsSinceLastRestart,
17
  getSearchesWithUnresponsiveEnginesSinceLastRestart,
 
85
  getSearchesWithUnresponsiveEnginesSinceLastRestart(),
86
  searchesWithAllResultsDiscarded:
87
  getSearchesWithAllResultsDiscardedSinceLastRestart(),
88
+ searchesServedByFallback: getSearchesServedByFallback(),
89
+ searchesFailedOnFallback: getSearchesFailedOnFallback(),
90
  biEncoderServiceStatus,
91
  rerankerServiceStatus,
92
  webSearchServiceStatus,