Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/client/harness-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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<string, unknown>).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<string, unknown>,
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) {
Expand Down
108 changes: 108 additions & 0 deletions tests/client/harness-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading