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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
326db0659f
commit
a28067f61e
@@ -18,7 +18,7 @@ import type {
|
|||||||
WorkPackageSummary,
|
WorkPackageSummary,
|
||||||
} from "../client/types.ts";
|
} from "../client/types.ts";
|
||||||
import { resolveClient, resolveProjectId } from "./helper.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";
|
import type { FieldSpec, MatchMode } from "../search/rank.ts";
|
||||||
|
|
||||||
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
|
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
|
||||||
@@ -147,22 +147,34 @@ export async function searchWorkPackages(
|
|||||||
assigneeId: options?.assigneeId,
|
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<WorkPackageSummary[]> => {
|
const fetchCandidates = async (): Promise<WorkPackageSummary[]> => {
|
||||||
const [precise, broad] = await Promise.all([
|
const [precise, broad] = await Promise.all([
|
||||||
listWorkPackages(
|
listCandidates({ ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES }),
|
||||||
{ ...sharedFilters, subject: query, pageSize: MAX_WORK_PACKAGE_CANDIDATES },
|
|
||||||
opClient
|
|
||||||
).catch(() => ({ items: [] as WorkPackageSummary[] })),
|
|
||||||
matchMode === "exact"
|
matchMode === "exact"
|
||||||
? Promise.resolve({ items: [] as WorkPackageSummary[] })
|
? Promise.resolve({ items: [] as WorkPackageSummary[] })
|
||||||
: listWorkPackages(
|
: listCandidates({
|
||||||
{
|
...sharedFilters,
|
||||||
...sharedFilters,
|
pageSize: MAX_WORK_PACKAGE_CANDIDATES,
|
||||||
pageSize: MAX_WORK_PACKAGE_CANDIDATES,
|
sortBy: '[["updatedAt","desc"]]',
|
||||||
sortBy: '[["updatedAt","desc"]]',
|
}),
|
||||||
},
|
|
||||||
opClient
|
|
||||||
).catch(() => ({ items: [] as WorkPackageSummary[] })),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const byId = new Map<number, WorkPackageSummary>();
|
const byId = new Map<number, WorkPackageSummary>();
|
||||||
|
|||||||
+56
-1
@@ -14,7 +14,11 @@ import {
|
|||||||
listUsers,
|
listUsers,
|
||||||
} from "../src/services/metadata.ts";
|
} from "../src/services/metadata.ts";
|
||||||
import * as domainServices from "../src/services/index.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";
|
import { runWithContext, type RequestContext } from "../src/context.ts";
|
||||||
|
|
||||||
describe("Domain Services Helper", () => {
|
describe("Domain Services Helper", () => {
|
||||||
@@ -884,5 +888,56 @@ describe("Work Package Fuzzy Search", () => {
|
|||||||
);
|
);
|
||||||
expect(result.elements).toHaveLength(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user