fix(frontend): decode blob error bodies before building error toast

Tool POSTs use responseType "blob", so error.response.data reaches
handleHttpError as an unread Blob. The toast message was built from that raw
Blob before the blob was decoded, so JSON.stringify(Blob) returned "{}", the
message was rejected as unhelpful, and every server error fell back to the
generic "There was an error processing your request." text.

Decode the response body once, up front, and pass the decoded body into
extractAxiosErrorMessage. The server explanation now reaches the toast for
every tool that posts with responseType blob, which also makes the merge 500
diagnosable.

Generated-By: PostHog Desktop
Task-Id: 1544e247-e89a-478f-8b54-6fce70390e6a
This commit is contained in:
posthog-eu[bot]
2026-08-21 23:10:10 +00:00
committed by GitHub
parent 1cb914023c
commit 1fc14a7da2
3 changed files with 68 additions and 6 deletions
@@ -0,0 +1,54 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { handleHttpError } from "@app/services/httpErrorHandler";
const alertMock = vi.hoisted(() => vi.fn());
vi.mock("@app/components/toast", () => ({
alert: (options: unknown) => alertMock(options),
}));
// Reproduces a tool POST: responseType "blob" makes error.response.data a Blob.
// jsdom's Blob has no .text(), so mirror the browser API the decode path uses.
function blobAxiosError(status: number, body: string, contentType: string) {
return {
isAxiosError: true,
config: { url: "/api/v1/general/merge-pdfs" },
response: {
status,
statusText: "",
data: { type: contentType, text: () => Promise.resolve(body) },
},
};
}
describe("handleHttpError — blob error bodies", () => {
beforeEach(() => {
alertMock.mockClear();
});
test("shows the decoded server message from a JSON blob body", async () => {
await handleHttpError(
blobAxiosError(
500,
JSON.stringify({ message: "JPDFium merge failed" }),
"application/json",
),
);
expect(alertMock).toHaveBeenCalledTimes(1);
expect(alertMock.mock.calls[0][0]).toMatchObject({
title: "Server error",
body: "JPDFium merge failed",
});
});
test("shows the decoded server message from a plain-text blob body", async () => {
await handleHttpError(
blobAxiosError(500, "JPDFium merge failed", "text/plain"),
);
expect(alertMock).toHaveBeenCalledTimes(1);
expect(alertMock.mock.calls[0][0]).toMatchObject({
body: "JPDFium merge failed",
});
});
});
@@ -147,10 +147,9 @@ export async function handleHttpError(error: any): Promise<boolean> {
if (handleSaaSError(error)) return true;
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
// Decode response data ONCE. Tool POSTs use responseType "blob", so
// error.response.data is an unread Blob here. The decoded body drives the
// toast message, file-ID extraction, and special-toast matching alike.
const raw = error?.response?.data as any;
let normalized: unknown = raw;
try {
@@ -159,6 +158,9 @@ export async function handleHttpError(error: any): Promise<boolean> {
console.debug("normalizeAxiosErrorData", e);
}
// Compute title/body (friendly) from the decoded body, not the raw Blob.
const { title, body } = extractAxiosErrorMessage(error, normalized);
// 1) If server sends structured file IDs for failures, also mark them errored in UI
try {
const ids = extractErrorFileIds(normalized);
@@ -25,7 +25,10 @@ function titleForStatus(status?: number): string {
return "Request failed";
}
export function extractAxiosErrorMessage(error: any): {
export function extractAxiosErrorMessage(
error: any,
decodedData?: unknown,
): {
title: string;
body: string;
} {
@@ -33,7 +36,10 @@ export function extractAxiosErrorMessage(error: any): {
const status = error.response?.status;
const _statusText = error.response?.statusText || "";
let parsed: any = undefined;
const raw = error.response?.data;
// Tool POSTs use responseType "blob", so error.response.data is an unread
// Blob. Prefer the decoded body when the caller supplies it, otherwise the
// message falls back to the generic text and the server reason is lost.
const raw = decodedData !== undefined ? decodedData : error.response?.data;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);