From 3daf2dd676c3fca56931b5b4f267e8203635b31f Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 25 Apr 2026 21:53:59 +0000 Subject: [PATCH] fix: detect 404 from @kubernetes/client-node v1.x ApiException (FAR-85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.x ApiException exposes the HTTP status as `code`, not `statusCode`. Both `isNotFound` (k8s-client) and `isK8s404` (execute) only checked `statusCode`/`response.statusCode`, so 404s were never recognized: - `getPvc` re-threw the 404 instead of returning null, which bubbled up through `ensureAgentDbPvc` as `k8s_job_create_failed` with the raw "persistentvolumeclaims X not found" body — the symptom in FAR-85. - The PVC was never actually created, because the existence check threw before reaching `createPvc`. Add `code === 404` to both predicates and a regression test for `isK8s404`. Co-Authored-By: Paperclip --- package.json | 2 +- src/server/execute.test.ts | 27 +++++++++++++++++++++++++++ src/server/execute.ts | 2 ++ src/server/k8s-client.ts | 2 ++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b656816..3fd2eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paperclip-adapter-opencode-k8s", - "version": "0.1.26", + "version": "0.1.27", "description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs", "license": "MIT", "type": "module", diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index 6f2f1e1..8d912c9 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -1185,3 +1185,30 @@ describe("ensureAgentDbPvc — unit", () => { expect(vi.mocked(getPvc)).toHaveBeenCalledWith(NAMESPACE, `opencode-db-${expectedSlug}`, undefined); }); }); + +describe("isK8s404", () => { + it("recognizes @kubernetes/client-node v1.x ApiException with code=404", async () => { + const { isK8s404 } = await import("./execute.js"); + const err = Object.assign(new Error("not found"), { code: 404 }); + expect(isK8s404(err)).toBe(true); + }); + + it("still recognizes legacy errors with statusCode=404", async () => { + const { isK8s404 } = await import("./execute.js"); + const err = Object.assign(new Error("not found"), { statusCode: 404 }); + expect(isK8s404(err)).toBe(true); + }); + + it("still recognizes errors with response.statusCode=404", async () => { + const { isK8s404 } = await import("./execute.js"); + const err = Object.assign(new Error("not found"), { response: { statusCode: 404 } }); + expect(isK8s404(err)).toBe(true); + }); + + it("returns false for non-404 errors", async () => { + const { isK8s404 } = await import("./execute.js"); + expect(isK8s404(Object.assign(new Error("server error"), { code: 500 }))).toBe(false); + expect(isK8s404(new Error("plain error"))).toBe(false); + expect(isK8s404(null)).toBe(false); + }); +}); diff --git a/src/server/execute.ts b/src/server/execute.ts index 34d3c16..c428d47 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -29,6 +29,8 @@ const LOG_EXIT_COMPLETION_GRACE_MS = parseInt(process.env.LOG_EXIT_COMPLETION_GR export function isK8s404(err: unknown): boolean { if (!(err instanceof Error)) return false; const asAny = err as unknown as Record; + // @kubernetes/client-node v1.x ApiException exposes HTTP status as `code`. + if (typeof asAny.code === "number" && asAny.code === 404) return true; if (typeof asAny.statusCode === "number" && asAny.statusCode === 404) return true; const resp = asAny.response as Record | undefined; if (typeof resp?.statusCode === "number" && resp.statusCode === 404) return true; diff --git a/src/server/k8s-client.ts b/src/server/k8s-client.ts index 4937920..af29928 100644 --- a/src/server/k8s-client.ts +++ b/src/server/k8s-client.ts @@ -178,6 +178,8 @@ export function resetCache(): void { function isNotFound(err: unknown): boolean { if (!(err instanceof Error)) return false; const asAny = err as unknown as Record; + // @kubernetes/client-node v1.x ApiException exposes HTTP status as `code`. + if (typeof asAny.code === "number" && asAny.code === 404) return true; if (typeof asAny.statusCode === "number" && asAny.statusCode === 404) return true; const resp = asAny.response as Record | undefined; return typeof resp?.statusCode === "number" && resp.statusCode === 404;