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
5 changes: 5 additions & 0 deletions .changeset/warm-panda-drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🥅 improve manteca error fingerprinting
5 changes: 5 additions & 0 deletions .changeset/xryv-lcxa-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

🥅 classify panda errors by name and status
8 changes: 4 additions & 4 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
getUser(credential.pandaId).catch((error: unknown) => {
const issue = noUser(error);
if (!issue) throw error;
const shouldCapture = issue.type === "NotFoundError" || status === "ACTIVE";
const shouldCapture = issue.error.status === 404 || status === "ACTIVE";
if (shouldCapture) {
withScope((scope) => {
scope.addEventProcessor((event) => {
Expand Down Expand Up @@ -392,7 +392,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
const issue = noUser(error);
if (!issue) throw error;
const hasCardHistory = credential.cards.length > 0;
const shouldCapture = issue.type === "NotFoundError" || hasCardHistory;
const shouldCapture = issue.error.status === 404 || hasCardHistory;
if (shouldCapture) {
withScope((scope) => {
scope.addEventProcessor((event) => {
Expand Down Expand Up @@ -568,13 +568,13 @@ const CardUUID = pipe(string(), uuid());

function noUser(error: unknown) {
if (!(error instanceof ServiceError)) return;
if (error.status === 404 && error.name.includes("NotFound")) return { error, type: "NotFoundError" as const };
if (error.status === 404 && error.name.includes("NotFound")) return { error, type: error.name };
if (
error.status === 403 &&
error.name.includes("Forbidden") &&
error.message.toLowerCase().includes("not approved")
) {
return { error, type: "ForbiddenError" as const };
return { error, type: error.name };
}
}

Expand Down
42 changes: 42 additions & 0 deletions server/test/utils/manteca.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,48 @@ describe("manteca utils", () => {
cause: "internal error",
});
});

it("extracts field path from errors array stripping pii", async () => {
const body =
'{"internalStatus":"BAD_REQUEST","message":"Bad request.","errors":["personalData..phoneNumber has wrong value *1234567890"]}';
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(400, body));

const rejection = manteca.getUser(account);
await expect(rejection).rejects.toBeInstanceOf(ServiceError);
await expect(rejection).rejects.toMatchObject({
name: "MantecaBadRequest",
status: 400,
message: "personalData..phoneNumber has wrong value",
cause: body,
});
});

it("falls back to message when errors array is absent", async () => {
const body = '{"internalStatus":"BAD_REQUEST","message":"Bad request."}';
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(400, body));

const rejection = manteca.getUser(account);
await expect(rejection).rejects.toMatchObject({ name: "MantecaBadRequest", message: "Bad request." });
});

it("keeps errors entry without has wrong value pattern", async () => {
const body = '{"internalStatus":"BAD_REQUEST","errors":["personalData..email is required"]}';
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(400, body));

const rejection = manteca.getUser(account);
await expect(rejection).rejects.toMatchObject({
name: "MantecaBadRequest",
message: "personalData..email is required",
});
});

it("falls back to generic classification without internalStatus", async () => {
const body = '{"message":"something went wrong"}';
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(422, body));

const rejection = manteca.getUser(account);
await expect(rejection).rejects.toMatchObject({ name: "Manteca422", message: "something went wrong" });
});
});

describe("getQuote", () => {
Expand Down
32 changes: 32 additions & 0 deletions server/test/utils/panda.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import "../mocks/sentry";

import { describe, expect, it, vi } from "vitest";

import * as panda from "../../utils/panda";
import ServiceError from "../../utils/ServiceError";

describe("panda request", () => {
it("extracts entity from url on not found", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve('{"message":"Not Found","error":"NotFoundError","statusCode":404}'),
} as Response);

const rejection = panda.getUser("some-id");
await expect(rejection).rejects.toBeInstanceOf(ServiceError);
await expect(rejection).rejects.toMatchObject({ name: "PandaNotFound", status: 404, message: "user" });
});

it("extracts card entity from url on not found", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: false,
status: 404,
text: () => Promise.resolve('{"message":"Not Found","error":"NotFoundError","statusCode":404}'),
} as Response);

const rejection = panda.getCard("some-id");
await expect(rejection).rejects.toBeInstanceOf(ServiceError);
await expect(rejection).rejects.toMatchObject({ name: "PandaNotFound", status: 404, message: "card" });
});
});
4 changes: 4 additions & 0 deletions server/utils/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ async function request<TInput, TOutput, TIssue extends BaseIssue<unknown>>(
if (response.status === 404 && (!raw || lower.includes("not found"))) type = "NotFoundError";
if (response.status === 403 && (!raw || lower.includes("not approved"))) type = "ForbiddenError";
}
if (message === "Not Found") {
const entity = url.split("/")[2]?.replace(/s$/, "");
if (entity) message = entity;
}
throw new ServiceError("Panda", response.status, raw, type, message);
}
const rawBody = await response.arrayBuffer();
Expand Down
18 changes: 17 additions & 1 deletion server/utils/ramps/manteca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,23 @@ async function request<TInput, TOutput, TIssue extends BaseIssue<unknown>>(
signal: AbortSignal.timeout(timeout),
});

if (!response.ok) throw new ServiceError("Manteca", response.status, await response.text());
if (!response.ok) {
const raw = await response.text();
let type: string | undefined;
let detail: string | undefined;
try {
const payload = JSON.parse(raw) as unknown;
if (typeof payload === "object" && payload !== null && !Array.isArray(payload)) {
const p = payload as Record<string, unknown>;
if (typeof p.internalStatus === "string" && p.internalStatus)
type = p.internalStatus.toLowerCase().replaceAll(/(?:^|_)\w/g, (c) => c.slice(-1).toUpperCase());
if (Array.isArray(p.errors) && typeof p.errors[0] === "string" && p.errors[0])
detail = p.errors[0].replace(/(has wrong value).*/, "$1");
else if (typeof p.message === "string" && p.message) detail = p.message;
}
} catch {} // eslint-disable-line no-empty -- non-json manteca errors use fallback classification
throw new ServiceError("Manteca", response.status, raw, type, detail);
}
const rawBody = await response.arrayBuffer();
if (rawBody.byteLength === 0) return parse(schema, {});
return parse(schema, JSON.parse(new TextDecoder().decode(rawBody)));
Expand Down
Loading