From a28067f61e6173389b7ce86c345431984ea985aa Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 18 Sep 2026 11:34:17 +0200 Subject: [PATCH] fix(work-packages): propagate fatal auth/rate-limit errors from candidate fetch The union candidate fetch wrapped both listWorkPackages() legs in an unconditional .catch(() => ({ items: [] })), swallowing 401/403/429 and OpenProjectAuthenticationError along with genuinely transient failures. An expired token would silently degrade to "0 candidates", and the LLM would report work packages as not found rather than surfacing the auth failure. Replace the inline catches with a shared listCandidates() helper that uses isFatalSearchError (already used by searchPipeline) to rethrow fatal errors and only swallow non-fatal ones, preserving the property that a bad response on one leg doesn't kill the other leg's candidates. Co-Authored-By: Claude Opus 5 (1M context) --- src/services/work-packages.ts | 38 +++++++++++++++-------- tests/services.test.ts | 57 ++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/services/work-packages.ts b/src/services/work-packages.ts index 48c9605..d97a6c4 100644 --- a/src/services/work-packages.ts +++ b/src/services/work-packages.ts @@ -18,7 +18,7 @@ import type { WorkPackageSummary, } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; -import { searchPipeline } from "../search/pipeline.ts"; +import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts"; import type { FieldSpec, MatchMode } from "../search/rank.ts"; export interface ListWorkPackagesParams extends WorkPackageFilterParams { @@ -147,22 +147,34 @@ export async function searchWorkPackages( assigneeId: options?.assigneeId, }; + // A non-fatal failure on one leg (e.g. a transient 500) must not kill the + // whole search: the other leg's candidates are still worth ranking. A + // fatal failure (401/403/429) must propagate rather than degrade into "0 + // candidates" — an expired token must not be reported to the model as "no + // results found". + const listCandidates = async ( + params: ListWorkPackagesParams + ): Promise<{ items: WorkPackageSummary[] }> => { + try { + return await listWorkPackages(params, opClient); + } catch (error: unknown) { + if (isFatalSearchError(error)) { + throw error; + } + return { items: [] }; + } + }; + const fetchCandidates = async (): Promise => { const [precise, broad] = await Promise.all([ - listWorkPackages( - { ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }, - opClient - ).catch(() => ({ items: [] as WorkPackageSummary[] })), + listCandidates({ ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }), matchMode === "exact" ? Promise.resolve({ items: [] as WorkPackageSummary[] }) - : listWorkPackages( - { - ...sharedFilters, - pageSize: MAX_WORK_PACKAGE_CANDIDATES, - sortBy: '[["updatedAt","desc"]]', - }, - opClient - ).catch(() => ({ items: [] as WorkPackageSummary[] })), + : listCandidates({ + ...sharedFilters, + pageSize: MAX_WORK_PACKAGE_CANDIDATES, + sortBy: '[["updatedAt","desc"]]', + }), ]); const byId = new Map(); diff --git a/tests/services.test.ts b/tests/services.test.ts index 9a2c162..978b29a 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -14,7 +14,11 @@ import { listUsers, } from "../src/services/metadata.ts"; import * as domainServices from "../src/services/index.ts"; -import { OpenProjectClient } from "../src/client/api-client.ts"; +import { + OpenProjectClient, + OpenProjectAuthenticationError, + OpenProjectError, +} from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; describe("Domain Services Helper", () => { @@ -884,5 +888,56 @@ describe("Work Package Fuzzy Search", () => { ); expect(result.elements).toHaveLength(0); }); + + test("propagates an auth failure from candidate fetch instead of reporting no results", async () => { + const client = { + get: async () => { + throw new OpenProjectAuthenticationError(); + }, + } as unknown as OpenProjectClient; + + await expect(searchWorkPackages("anything", {}, client)).rejects.toThrow( + OpenProjectAuthenticationError + ); + }); + + test("a non-fatal failure on one candidate leg still returns results from the other", async () => { + let calls = 0; + const client = { + get: async (path: string) => { + calls++; + // Fail the precise (subject-filtered) leg non-fatally; the broad + // leg (no subject filter) should still supply candidates. + if (calls === 1) { + throw new OpenProjectError("boom", { statusCode: 500 }); + } + return { + _type: "Collection", + total: 1, + count: 1, + pageSize: 100, + offset: 1, + _embedded: { + elements: [ + { + _type: "WorkPackage", + id: 1, + subject: "Fix login redirect", + _links: { + type: { title: "Bug" }, + status: { title: "New" }, + project: { href: "/api/v3/projects/1", title: "Demo project" }, + }, + updatedAt: "2026-09-10T00:00:00Z", + }, + ], + }, + }; + }, + } as unknown as OpenProjectClient; + + const result = await searchWorkPackages("login", {}, client); + expect(result.elements[0]!.workPackage.id).toBe(1); + }); });