Skip to content

Commit d4bd62c

Browse files
ihor-sokoliukclaudecodex
committed
refactor(search): tighten fanout merge, cooldown error, and skip-list lookup (FEAT-047)
Final PR #128 review polish. Fanout merge now sets number_of_results to the merged result count so the JSON envelope is self-consistent. The all-instances- failed error emits a distinct cooldown-specific message (no misleading "failed", no double space) when every instance was skipped due to cooldown rather than attempted. skippedInstances membership now uses a Set instead of Array.includes. URL/result behavior is otherwise unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Codex <noreply@openai.com>
1 parent 746e3bb commit d4bd62c

2 files changed

Lines changed: 50 additions & 3 deletions

File tree

__tests__/unit/search.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,6 +1953,7 @@ async function runTests() {
19531953
return createMockFetch({
19541954
json: {
19551955
query: 'fanout',
1956+
number_of_results: 2,
19561957
results: [
19571958
{ title: 'Lower Duplicate', content: 'Low', url: 'https://Example.com/same#section', score: 0.2 },
19581959
{ title: 'Missing Score', content: 'No score', url: 'not a url', score: undefined },
@@ -1963,6 +1964,7 @@ async function runTests() {
19631964
return createMockFetch({
19641965
json: {
19651966
query: 'fanout',
1967+
number_of_results: 3,
19661968
results: [
19671969
{ title: 'Highest', content: 'High', url: 'https://example.com/high', score: 0.95 },
19681970
{ title: 'Higher Duplicate', content: 'Better', url: 'https://example.com/same', score: 0.7 },
@@ -1978,6 +1980,40 @@ async function runTests() {
19781980
assert.deepEqual(requestedHosts.sort(), ['https://one.example.com', 'https://two.example.com']);
19791981
assert.deepEqual(payload.servedBy, ['https://one.example.com', 'https://two.example.com']);
19801982
assert.deepEqual(payload.results.map((entry: any) => entry.title), ['Highest', 'Higher Duplicate', 'Raw URL Copy']);
1983+
assert.equal(payload.number_of_results, payload.results.length);
1984+
assert.equal(payload.number_of_results, 3);
1985+
1986+
fetchMocker.restore();
1987+
envManager.restore();
1988+
}, results);
1989+
1990+
await testFunction('all cooled down instances throw cooldown-specific message without double spaces', async () => {
1991+
clearSearxngInstanceStateForTests();
1992+
envManager.set('SEARXNG_URL', 'https://cooled-one.example.com;https://cooled-two.example.com');
1993+
1994+
for (const instanceUrl of ['https://cooled-one.example.com', 'https://cooled-two.example.com']) {
1995+
recordSearxngInstanceFailure(instanceUrl, Date.now());
1996+
recordSearxngInstanceFailure(instanceUrl, Date.now());
1997+
recordSearxngInstanceFailure(instanceUrl, Date.now());
1998+
}
1999+
2000+
const mockServer = createMockServer();
2001+
let fetchCalled = false;
2002+
fetchMocker.mock(async () => {
2003+
fetchCalled = true;
2004+
return createMockFetch({ json: { results: [] } })('https://unused.example.com');
2005+
});
2006+
2007+
try {
2008+
await performWebSearch(mockServer as any, 'all cooled');
2009+
assert.fail('Expected all-cooled error');
2010+
} catch (error: any) {
2011+
assert.ok(error.message.includes('All configured SearXNG instances are in cooldown after repeated failures'), error.message);
2012+
assert.ok(error.message.includes('https://cooled-one.example.com'), error.message);
2013+
assert.ok(error.message.includes('https://cooled-two.example.com'), error.message);
2014+
assert.ok(!error.message.includes(' '), error.message);
2015+
}
2016+
assert.equal(fetchCalled, false);
19812017

19822018
fetchMocker.restore();
19832019
envManager.restore();

src/search.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,12 @@ function hasSearchResults(data: SearXNGWeb): boolean {
576576
}
577577

578578
function createAllInstancesFailedError(failures: FailedInstanceResult[], skippedInstances: string[]): MCPSearXNGError {
579+
if (failures.length === 0 && skippedInstances.length > 0) {
580+
return new MCPSearXNGError(
581+
`All configured SearXNG instances are in cooldown after repeated failures: ${skippedInstances.join(", ")}.`
582+
);
583+
}
584+
579585
const failureDetails = failures
580586
.map(({ instanceUrl, error }) => `${instanceUrl}: ${error instanceof Error ? error.message : String(error)}`)
581587
.join("; ");
@@ -592,7 +598,8 @@ async function performFailoverSearch(
592598
request: SearchRequest,
593599
): Promise<MultiInstanceSearchResult> {
594600
const healthyInstances = getHealthySearxngInstances(instances);
595-
const skippedInstances = instances.filter((instanceUrl) => !healthyInstances.includes(instanceUrl));
601+
const healthySet = new Set(healthyInstances);
602+
const skippedInstances = instances.filter((instanceUrl) => !healthySet.has(instanceUrl));
596603
const failures: FailedInstanceResult[] = [];
597604
const emptyResults: EmptyInstanceResult[] = [];
598605

@@ -656,9 +663,12 @@ function mergeFanoutResults(results: InstanceSearchResult[]): SearXNGWeb {
656663
}
657664
}
658665

666+
const mergedResults = [...byUrl.values()].sort((a, b) => resultScore(b) - resultScore(a));
667+
659668
return {
660669
...base,
661-
results: [...byUrl.values()].sort((a, b) => resultScore(b) - resultScore(a)),
670+
number_of_results: mergedResults.length,
671+
results: mergedResults,
662672
};
663673
}
664674

@@ -668,7 +678,8 @@ async function performFanoutSearch(
668678
request: SearchRequest,
669679
): Promise<MultiInstanceSearchResult> {
670680
const healthyInstances = getHealthySearxngInstances(instances);
671-
const skippedInstances = instances.filter((instanceUrl) => !healthyInstances.includes(instanceUrl));
681+
const healthySet = new Set(healthyInstances);
682+
const skippedInstances = instances.filter((instanceUrl) => !healthySet.has(instanceUrl));
672683
const settledResults = await Promise.all(healthyInstances.map(async (instanceUrl) => {
673684
try {
674685
const result = await fetchSearchFromInstance(mcpServer, instanceUrl, request);

0 commit comments

Comments
 (0)