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
7 changes: 7 additions & 0 deletions .changeset/fix-format-errors-missing-await.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/format-errors": patch
---

Fix error formatting to reliably return fallback responses on failure

Previously, if something went wrong while formatting a pretty error page, the failure could go unhandled, resulting in no response being returned to the user. Now, errors during formatting are properly caught, ensuring users always receive a 500 JSON fallback response.
4 changes: 3 additions & 1 deletion packages/format-errors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"check:lint": "eslint . --max-warnings=0 --cache",
"check:type": "tsc",
"deploy": "wrangler deploy",
"start": "wrangler dev"
"start": "wrangler dev",
"test:ci": "vitest run"
},
"devDependencies": {
"@cloudflare/eslint-config-shared": "workspace:*",
Expand All @@ -17,6 +18,7 @@
"promjs": "^0.4.2",
"toucan-js": "4.0.0",
"tsconfig": "*",
"vitest": "catalog:default",
"wrangler": "workspace:*",
"zod": "^3.22.3"
},
Expand Down
107 changes: 107 additions & 0 deletions packages/format-errors/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import type { Payload } from "../index";

// Mock external dependencies that can't be resolved by Vite in test env
vi.mock("promjs", () => {
const mockCounter = { inc: vi.fn() };
const mockRegistry = {
create: vi.fn(() => mockCounter),
metrics: vi.fn(() => ""),
};
return { default: () => mockRegistry };
});

vi.mock("toucan-js", () => {
return {
Toucan: vi.fn().mockImplementation(() => ({
captureException: vi.fn(),
})),
};
});

describe("handlePrettyErrorRequest", () => {
it("should propagate async rejections to callers", async () => {
// Mock Youch to throw asynchronously
vi.doMock("../Youch", () => {
return {
default: vi.fn().mockImplementation(() => ({
addLink: vi.fn(),
toHTML: vi.fn().mockRejectedValue(new Error("Youch async error")),
})),
};
});

// handlePrettyErrorRequest is async — if it's not awaited, async
// rejections (e.g. from Youch.toHTML()) won't be caught by a
// surrounding try/catch, leading to unhandled promise rejections.
// This test verifies that the function properly rejects so that
// callers who `await` it can catch the error.
const { handlePrettyErrorRequest } = await import("../index");

const payload: Payload = {
url: "https://example.com",
method: "GET",
headers: { "content-type": "text/html" },
error: {
message: "Test error",
name: "Error",
stack: "Error: Test error\n at test:1:1",
},
};

await expect(handlePrettyErrorRequest(payload)).rejects.toThrow(
"Youch async error"
);
});
});

describe("reviveError", () => {
it("should revive a plain Error", async () => {
const { reviveError } = await import("../index");
const error = reviveError({
message: "test",
name: "Error",
stack: "Error: test\n at foo:1:1",
});
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("test");
expect(error.name).toBe("Error");
expect(error.stack).toBe("Error: test\n at foo:1:1");
});

it("should revive a TypeError", async () => {
const { reviveError } = await import("../index");
const error = reviveError({
message: "x is not a function",
name: "TypeError",
});
expect(error).toBeInstanceOf(TypeError);
expect(error.message).toBe("x is not a function");
});

it("should revive an error with a cause", async () => {
const { reviveError } = await import("../index");
const error = reviveError({
message: "outer",
name: "Error",
cause: {
message: "inner",
name: "RangeError",
},
});
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("outer");
expect(error.cause).toBeInstanceOf(RangeError);
expect((error.cause as Error).message).toBe("inner");
});

it("should fall back to Error for unknown error names", async () => {
const { reviveError } = await import("../index");
const error = reviveError({
message: "custom",
name: "CustomError",
});
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe("CustomError");
});
});
2 changes: 1 addition & 1 deletion packages/format-errors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export default {
}

try {
return handlePrettyErrorRequest(payload);
return await handlePrettyErrorRequest(payload);
} catch (e) {
sentry.captureException(e);
const errorCounter = registry.create(
Expand Down
23 changes: 23 additions & 0 deletions packages/format-errors/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineProject, mergeConfig } from "vitest/config";
import configShared from "../../vitest.shared";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default mergeConfig(
configShared,
defineProject({
resolve: {
alias: {
// promjs has a broken package.json (main points to lib/index.js
// which doesn't exist in the installed package). Alias it to the
// actual entry point so Vite can resolve it in tests.
promjs: path.resolve(__dirname, "node_modules/promjs/index.js"),
},
},
test: {
include: ["src/__tests__/**/*.{test,spec}.{ts,js}"],
},
})
);
30 changes: 11 additions & 19 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading