From 00f96dcd11d1c08ad689d4db6c40e6a8525f1216 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Sun, 15 Feb 2026 21:36:18 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=A5=85=20server:=20fingerprint=20api=20er?= =?UTF-8?q?rors=20by=20response=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/twenty-bananas-cheer.md | 5 +++++ server/index.ts | 28 +++++++++++++++++++++++++- server/test/utils/manteca.test.ts | 32 +++++++++++++++--------------- server/utils/ramps/manteca.ts | 2 +- server/utils/sardine.ts | 2 +- 5 files changed, 50 insertions(+), 19 deletions(-) create mode 100644 .changeset/twenty-bananas-cheer.md diff --git a/.changeset/twenty-bananas-cheer.md b/.changeset/twenty-bananas-cheer.md new file mode 100644 index 000000000..b27a5a8e5 --- /dev/null +++ b/.changeset/twenty-bananas-cheer.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🥅 fingerprint api errors by response message diff --git a/server/index.ts b/server/index.ts index 84e4e02c7..0f4d0042a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -275,7 +275,33 @@ frontend.use( app.route("/", frontend); app.onError((error, c) => { - captureException(error, { level: "error", tags: { unhandled: true } }); + let fingerprint: string[] | undefined; + if (error instanceof Error) { + const status = error.message.slice(0, 3); + const hasStatus = /^\d{3}$/.test(status); + const hasBodyFormat = error.message.length === 3 || error.message[3] === " "; + const body = hasBodyFormat && error.message.length > 3 ? error.message.slice(4) : undefined; + if (hasStatus && hasBodyFormat) fingerprint = ["{{ default }}", status]; + if (hasStatus && hasBodyFormat && body !== undefined) { + try { + const json = JSON.parse(body) as { code?: unknown; error?: unknown; message?: unknown }; + fingerprint = [ + "{{ default }}", + status, + ...("code" in json + ? [String(json.code)] + : typeof json.error === "string" + ? [json.error] + : typeof json.message === "string" + ? [json.message] + : []), + ]; + } catch { + fingerprint = ["{{ default }}", status, body]; + } + } + } + captureException(error, { level: "error", tags: { unhandled: true }, fingerprint }); return c.json({ code: "unexpected error", legacy: "unexpected error" }, 555 as UnofficialStatusCode); }); diff --git a/server/test/utils/manteca.test.ts b/server/test/utils/manteca.test.ts index 20430b639..ced2aa822 100644 --- a/server/test/utils/manteca.test.ts +++ b/server/test/utils/manteca.test.ts @@ -100,7 +100,7 @@ describe("manteca utils", () => { }); it("returns null when user not found", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(404, "USER_NF")); const result = await manteca.getUser(account); @@ -108,9 +108,9 @@ describe("manteca utils", () => { }); it("throws on other errors", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(500, "::500:: internal error")); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(500, "internal error")); - await expect(manteca.getUser(account)).rejects.toThrow("::500:: internal error"); + await expect(manteca.getUser(account)).rejects.toThrow("500 internal error"); }); }); @@ -134,7 +134,7 @@ describe("manteca utils", () => { }); it("returns undefined on error", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(500, "::500:: error")); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(500, "error")); const result = await manteca.getQuote("USDC_ARS"); @@ -172,7 +172,7 @@ describe("manteca utils", () => { it("throws INVALID_ORDER_SIZE on MIN_SIZE error", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce(mockFetchResponse({ ...mockBalanceBase, balance: { ARS: "1.00" } })) - .mockResolvedValueOnce(mockFetchError(400, "::400:: MIN_SIZE")); + .mockResolvedValueOnce(mockFetchError(400, "MIN_SIZE")); await expect(manteca.convertBalanceToUsdc("456", "ARS")).rejects.toThrow(ErrorCodes.INVALID_ORDER_SIZE); }); @@ -238,7 +238,7 @@ describe("manteca utils", () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 404, - text: () => Promise.resolve("::404:: USER_NF"), + text: () => Promise.resolve("USER_NF"), } as Response); const result = await manteca.getProvider(account, "AR"); @@ -284,7 +284,7 @@ describe("manteca utils", () => { it("returns ACTIVE with no limits when limits fetch fails", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce(mockFetchResponse(mockActiveUser)) - .mockResolvedValueOnce(mockFetchError(500, "::500:: limits error")); + .mockResolvedValueOnce(mockFetchError(500, "limits error")); const result = await manteca.getProvider(account, "AR"); @@ -368,7 +368,7 @@ describe("manteca utils", () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 404, - text: () => Promise.resolve("::404:: USER_NF"), + text: () => Promise.resolve("USER_NF"), } as Response); const result = await manteca.getProvider(account, "AR"); @@ -470,7 +470,7 @@ describe("manteca utils", () => { }); it("throws when no persona account found", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockFetchError(404, "USER_NF")); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined await expect(manteca.mantecaOnboarding(account, credentialId)).rejects.toThrow(ErrorCodes.NO_PERSONA_ACCOUNT); @@ -478,7 +478,7 @@ describe("manteca utils", () => { it("throws when no identity document found", async () => { vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")) + .mockResolvedValueOnce(mockFetchError(404, "USER_NF")) .mockResolvedValueOnce(mockFetchResponse(mockNewUserResponse)); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockPersonaAccount); vi.spyOn(persona, "getDocumentForManteca").mockResolvedValueOnce(undefined); // eslint-disable-line unicorn/no-useless-undefined @@ -488,7 +488,7 @@ describe("manteca utils", () => { it("throws when front document URL not found", async () => { vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")) + .mockResolvedValueOnce(mockFetchError(404, "USER_NF")) .mockResolvedValueOnce(mockFetchResponse(mockNewUserResponse)); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockPersonaAccount); vi.spyOn(persona, "getDocumentForManteca").mockResolvedValueOnce(mockIdentityDocument); @@ -502,11 +502,11 @@ describe("manteca utils", () => { it("throws INVALID_LEGAL_ID when initiateOnboarding returns legalId error", async () => { vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")) + .mockResolvedValueOnce(mockFetchError(404, "USER_NF")) .mockResolvedValueOnce( mockFetchError( 400, - '::400:: {"internalStatus":"BAD_REQUEST","message":"Bad request.","errors":["legalId has wrong value 20991231239"]}', + '{"internalStatus":"BAD_REQUEST","message":"Bad request.","errors":["legalId has wrong value 20991231239"]}', ), ); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockPersonaAccount); @@ -517,7 +517,7 @@ describe("manteca utils", () => { it("initiates onboarding for new user", async () => { vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")) + .mockResolvedValueOnce(mockFetchError(404, "USER_NF")) .mockResolvedValueOnce(mockFetchResponse(mockNewUserResponse)) .mockResolvedValueOnce(mockFetchResponse({ url: "https://presigned.url/front" })) .mockResolvedValueOnce(mockFetchResponse({ url: "https://presigned.url/back" })) @@ -555,7 +555,7 @@ describe("manteca utils", () => { const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")) + .mockResolvedValueOnce(mockFetchError(404, "USER_NF")) .mockResolvedValueOnce(mockFetchResponse(mockNewUserResponse)) .mockResolvedValueOnce(mockFetchResponse({ url: "https://presigned.url/front" })) .mockResolvedValueOnce(mockFetchResponse({ url: "https://presigned.url/back" })) @@ -583,7 +583,7 @@ describe("manteca utils", () => { const fetchSpy = vi .spyOn(globalThis, "fetch") - .mockResolvedValueOnce(mockFetchError(404, "::404:: USER_NF")) + .mockResolvedValueOnce(mockFetchError(404, "USER_NF")) .mockResolvedValueOnce(mockFetchResponse(mockNewUserResponse)) .mockResolvedValueOnce(mockFetchResponse({ url: "https://presigned.url/front" })) .mockResolvedValueOnce(mockFetchResponse({ url: "https://presigned.url/back" })) diff --git a/server/utils/ramps/manteca.ts b/server/utils/ramps/manteca.ts index 2d9d7e64c..fddd28364 100644 --- a/server/utils/ramps/manteca.ts +++ b/server/utils/ramps/manteca.ts @@ -701,7 +701,7 @@ async function request>( signal: AbortSignal.timeout(timeout), }); - if (!response.ok) throw new Error(`::${response.status}:: ${await response.text()}`); + if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); const rawBody = await response.arrayBuffer(); if (rawBody.byteLength === 0) return parse(schema, {}); return parse(schema, JSON.parse(new TextDecoder().decode(rawBody))); diff --git a/server/utils/sardine.ts b/server/utils/sardine.ts index 36cc4c8f8..d371107b3 100644 --- a/server/utils/sardine.ts +++ b/server/utils/sardine.ts @@ -59,7 +59,7 @@ async function request>( signal: AbortSignal.timeout(timeout), }); - if (!response.ok) throw new Error(`${response.status} ${await response.text()} ${url}`); + if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); const rawBody = await response.arrayBuffer(); if (rawBody.byteLength === 0) throw new Error(`Empty response body from ${url}`); return parse(schema, JSON.parse(new TextDecoder().decode(rawBody)));