Merge pull request #743 from webadderallorg/fix/735-marketplace-error-response

fix(extensions): handle marketplace outages safely
This commit is contained in:
PTMH-NMH
2026-07-11 11:30:12 +07:00
committed by GitHub
3 changed files with 133 additions and 3 deletions
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { formatMarketplaceHttpError } from "./errorUtils";
describe("formatMarketplaceHttpError", () => {
it("hides upstream HTML when the marketplace is unavailable", () => {
const html = "<!DOCTYPE html><html><body>SSL handshake failed</body></html>";
const message = formatMarketplaceHttpError({
status: 525,
contentType: "text/html; charset=UTF-8",
body: html,
});
expect(message).toBe(
"Marketplace is temporarily unavailable (HTTP 525). Please try again later.",
);
expect(message).not.toContain(html);
});
it("keeps a short JSON error for client-side request failures", () => {
expect(
formatMarketplaceHttpError({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid search query" }),
}),
).toBe("Marketplace request failed (HTTP 400): Invalid search query");
});
it("uses a JSON message when an error field is absent", () => {
expect(
formatMarketplaceHttpError({
status: 409,
contentType: "application/json",
body: JSON.stringify({ message: "Extension version already exists" }),
}),
).toBe("Marketplace request failed (HTTP 409): Extension version already exists");
});
it("prefers a string error when both JSON detail fields are present", () => {
expect(
formatMarketplaceHttpError({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: "Primary detail", message: "Secondary detail" }),
}),
).toBe("Marketplace request failed (HTTP 400): Primary detail");
});
it("hides malformed JSON bodies", () => {
const body = '{"error":"internal route details"';
const message = formatMarketplaceHttpError({
status: 400,
contentType: "application/json",
body,
});
expect(message).toBe("Marketplace request failed (HTTP 400).");
expect(message).not.toContain(body);
});
it("bounds long JSON details and marks truncation without splitting Unicode", () => {
const detail = `🚀${"x".repeat(200)}`;
const message = formatMarketplaceHttpError({
status: 400,
contentType: "application/problem+json",
body: JSON.stringify({ error: detail }),
});
expect(message).toBe(`Marketplace request failed (HTTP 400): 🚀${"x".repeat(198)}`);
expect(Array.from(message.split(": ")[1])).toHaveLength(200);
});
it("does not expose non-JSON response bodies", () => {
expect(
formatMarketplaceHttpError({
status: 404,
contentType: "text/plain",
body: "internal route details",
}),
).toBe("Marketplace request failed (HTTP 404).");
});
});
+41 -1
View File
@@ -1,3 +1,43 @@
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
const MAX_MARKETPLACE_ERROR_DETAIL_LENGTH = 200;
export function formatMarketplaceHttpError({
status,
contentType,
body,
}: {
status: number;
contentType: string | null;
body: string;
}): string {
if (status >= 500) {
return `Marketplace is temporarily unavailable (HTTP ${status}). Please try again later.`;
}
let detail: string | null = null;
if (contentType?.toLowerCase().includes("json")) {
try {
const payload: unknown = JSON.parse(body);
if (payload && typeof payload === "object") {
const { error, message } = payload as { error?: unknown; message?: unknown };
const value = typeof error === "string" ? error : message;
if (typeof value === "string" && value.trim()) {
const normalized = value.trim().replace(/\s+/g, " ");
const codePoints = Array.from(normalized);
detail =
codePoints.length > MAX_MARKETPLACE_ERROR_DETAIL_LENGTH
? `${codePoints.slice(0, MAX_MARKETPLACE_ERROR_DETAIL_LENGTH - 1).join("")}`
: normalized;
}
}
} catch {
// Malformed or non-API responses are intentionally not exposed to the renderer.
}
}
const summary = `Marketplace request failed (HTTP ${status})`;
return detail ? `${summary}: ${detail}` : `${summary}.`;
}
+8 -2
View File
@@ -12,7 +12,7 @@ import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { app } from "electron";
import { getErrorMessage } from "./errorUtils";
import { formatMarketplaceHttpError, getErrorMessage } from "./errorUtils";
import { getRegisteredExtensions, installExtensionFromPath } from "./extensionLoader";
import type {
ExtensionReview,
@@ -97,7 +97,13 @@ async function marketplaceFetch<T>(
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Marketplace API error ${response.status}: ${text}`);
throw new Error(
formatMarketplaceHttpError({
status: response.status,
contentType: response.headers.get("content-type"),
body: text,
}),
);
}
return (await response.json()) as T;