fix(wikis): stop swallowing fatal errors and marking failed scans complete

`getWikiPage`'s attachments fetch had a bare `catch` that swallowed
401/403/429 one function away from discovery code that correctly
rethrows them. During a 50-page discovery scan a rate limit was eaten
once per page and every page cached with `attachments: []`, then
reported to the model as fact.

The discovery probe loop incremented `consecutiveMisses` only on 404, so
a run where every probe failed with a 500 or a network error never
tripped the cutoff, never rethrew, and cached nothing — yet still ran
`discoveredCacheKeys.add(cacheKey)` unconditionally, marking discovery
complete. Every later wiki search then short-circuited on an empty
corpus and returned `[]` for the life of the process.

Discovery is now marked complete only when at least one page was cached
or the loop ended via the 404 cutoff. An all-errors scan satisfies
neither, so it retries; a genuinely empty wiki trips the cutoff and is
still marked complete rather than re-probed on every search.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-24 09:27:33 +02:00
co-authored by Claude Opus 5
parent 216b460c56
commit a8d3c23096
2 changed files with 141 additions and 3 deletions
+115
View File
@@ -729,3 +729,118 @@ describe("Wiki Fuzzy Search", () => {
expect(results).toHaveLength(0);
});
});
describe("Wiki Error Propagation", () => {
beforeEach(() => {
clearWikiCache();
});
function pageWithFailingAttachments(error: unknown) {
return {
baseUrl: "http://localhost:8080",
getCacheKey: () => "attachments-test",
get: async (path: string) => {
if (path.endsWith("/attachments")) {
throw error;
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Project Wiki",
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
};
}
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
}
test("getWikiPage propagates a rate limit from the attachments fetch", async () => {
await expect(
getWikiPage(
1,
pageWithFailingAttachments(new OpenProjectError("rate limited", { statusCode: 429 }))
)
).rejects.toThrow("rate limited");
});
test("getWikiPage propagates an authentication failure from the attachments fetch", async () => {
await expect(
getWikiPage(1, pageWithFailingAttachments(new OpenProjectAuthenticationError()))
).rejects.toThrow(OpenProjectAuthenticationError);
});
test("getWikiPage still degrades to an empty attachment list on a non-fatal failure", async () => {
const page = await getWikiPage(
1,
pageWithFailingAttachments(new OpenProjectError("boom", { statusCode: 500 }))
);
expect(page.id).toBe(1);
expect(page.attachments).toEqual([]);
});
test("a discovery scan that errors through every probe is not marked complete", async () => {
// Phase 1: every wiki page probe fails with a 500. Nothing is cached, and
// the 404 cutoff never trips, so discovery did not actually complete.
let failEverything = true;
const client = {
baseUrl: "http://localhost:8080",
getCacheKey: () => "flaky-tenant",
get: async (path: string) => {
if (path === "/api/v3/wiki_page_links") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (failEverything) {
throw new OpenProjectError("upstream exploded", { statusCode: 500 });
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Deployment Runbook",
text: { raw: "How to deploy the service" },
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
const first = await searchWikiPages({ query: "deployment" }, client);
expect(first).toEqual([]);
// Phase 2: the instance recovers. Discovery must run again rather than
// short-circuiting on a cache that was wrongly marked complete and
// returning [] for the life of the process.
failEverything = false;
const second = await searchWikiPages({ query: "deployment" }, client);
expect(second.map((page) => page.id)).toEqual([1]);
});
test("a genuinely empty wiki is marked complete and is not re-probed", async () => {
let probes = 0;
const client = {
baseUrl: "http://localhost:8080",
getCacheKey: () => "empty-tenant",
get: async (path: string) => {
if (path === "/api/v3/wiki_page_links") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
probes++;
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
expect(await searchWikiPages({ query: "anything" }, client)).toEqual([]);
const afterFirst = probes;
// The 404 cutoff is a real completion signal: five consecutive misses.
expect(afterFirst).toBe(5);
expect(await searchWikiPages({ query: "anything" }, client)).toEqual([]);
expect(probes).toBe(afterFirst);
});
});