From 0b8e06efc70d8ac6ae0db5ff2c377876189bf4ca Mon Sep 17 00:00:00 2001 From: Madhav Bansal Date: Thu, 20 Aug 2026 13:59:59 +0530 Subject: [PATCH] fix: surface NG error details in API failures Append details, detailedMessage, and extra responseMessages to the client error so agents see the real cause instead of only Unable to process JSON. --- src/client/harness-client.ts | 30 +++++++- tests/client/harness-client.test.ts | 108 ++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/client/harness-client.ts b/src/client/harness-client.ts index 18ace3e05..9fdaed26e 100644 --- a/src/client/harness-client.ts +++ b/src/client/harness-client.ts @@ -107,12 +107,40 @@ const ERROR_FIELD_ENRICHMENTS: Array<{ pathPrefix: string; field: string }> = [ { pathPrefix: "/loadTest/", field: "description" }, ]; +const MAX_ERROR_DETAIL_CHARS = 400; + +/** Extra NG fields (`details`, `detailedMessage`, `responseMessages`) not already in `message`. */ +function collectErrorDetail(parsed: Record, message: string): string | undefined { + const seen = new Set([message.trim()]); + const extras: string[] = []; + const add = (value: unknown): void => { + if (typeof value !== "string") return; + const text = value.trim(); + if (!text || seen.has(text)) return; + seen.add(text); + extras.push(text); + }; + + add(parsed.details); + add(parsed.detailedMessage); + for (const entry of Array.isArray(parsed.responseMessages) ? parsed.responseMessages : []) { + if (entry && typeof entry === "object") add((entry as Record).message); + } + + if (extras.length === 0) return undefined; + const detail = extras.join("; "); + return detail.length > MAX_ERROR_DETAIL_CHARS + ? `${detail.slice(0, MAX_ERROR_DETAIL_CHARS)}…` + : detail; +} + function enrichErrorMessage( rawMessage: string, parsed: Record, path: string, ): string { - let message = rawMessage; + const detail = collectErrorDetail(parsed, rawMessage); + let message = detail ? `${rawMessage} — ${detail}` : rawMessage; for (const { pathPrefix, field } of ERROR_FIELD_ENRICHMENTS) { const value = parsed[field]; if (path.startsWith(pathPrefix) && typeof value === "string" && value) { diff --git a/tests/client/harness-client.test.ts b/tests/client/harness-client.test.ts index dde7aaa72..66f835225 100644 --- a/tests/client/harness-client.test.ts +++ b/tests/client/harness-client.test.ts @@ -561,6 +561,114 @@ describe("HarnessClient", () => { }); }); + it("surfaces `details` when NG collapses the cause into the JSON-processing error", async () => { + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ + code: 400, + message: "Unable to process JSON", + details: "No spec should be provided with the inherit from delegate type", + }), + { status: 400 }, + )); + const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 })); + + await expect(client.request({ path: "/ng/api/connectors" })).rejects.toMatchObject({ + message: + "Unable to process JSON — No spec should be provided with the inherit from delegate type", + statusCode: 400, + }); + }); + + it("leaves the message alone when responseMessages only repeat it", async () => { + const message = + "Invalid request: Delegate Selector cannot be null for inherit from delegate credential type"; + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ + status: "ERROR", + code: "INVALID_REQUEST", + message, + correlationId: "df70c78c-0d47-422a-bd96-faeb9825bc3c", + detailedMessage: null, + responseMessages: [ + { + code: "INVALID_REQUEST", + level: "ERROR", + message, + exception: null, + failureTypes: [], + failureSubTypes: [], + additionalInfo: {}, + }, + ], + metadata: null, + }), + { status: 400 }, + )); + const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 })); + + await expect(client.request({ path: "/ng/api/connectors" })).rejects.toMatchObject({ + message, + harnessCode: "INVALID_REQUEST", + correlationId: "df70c78c-0d47-422a-bd96-faeb9825bc3c", + }); + }); + + it("surfaces detailedMessage and responseMessages that add new information", async () => { + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ + message: "Invalid request", + detailedMessage: "Field 'spec.credential' is required", + responseMessages: [ + { message: "Invalid request" }, + { message: "Connector type K8sCluster requires a credential block" }, + ], + }), + { status: 400 }, + )); + const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 })); + + await expect(client.request({ path: "/ng/api/connectors" })).rejects.toMatchObject({ + message: + "Invalid request — Field 'spec.credential' is required; " + + "Connector type K8sCluster requires a credential block", + }); + }); + + it("truncates oversized upstream detail", async () => { + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ message: "Unable to process JSON", details: "x".repeat(900) }), + { status: 400 }, + )); + const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 })); + + try { + await client.request({ path: "/ng/api/connectors" }); + expect.fail("should have thrown"); + } catch (err) { + const { message } = err as HarnessApiError; + expect(message).toBe(`Unable to process JSON — ${"x".repeat(400)}…`); + } + }); + + it("surfaces `details` on requestStream errors", async () => { + fetchSpy.mockResolvedValue(new Response( + JSON.stringify({ + code: 400, + message: "Unable to process JSON", + details: "No spec should be provided with the inherit from delegate type", + }), + { status: 400 }, + )); + const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 })); + + await expect( + client.requestStream({ method: "POST", path: "/ng/api/connectors" }), + ).rejects.toMatchObject({ + message: + "Unable to process JSON — No spec should be provided with the inherit from delegate type", + }); + }); + it("appends chaos description on requestStream errors", async () => { fetchSpy.mockResolvedValue(new Response( JSON.stringify({