Skip to content

Commit c7d0d18

Browse files
petebacondarwindevin-ai-integration[bot]dario-piotrowicz
authored
[format-errors] Await async handlePrettyErrorRequest in fetch handler's try/catch (cloudflare#12756)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Dario Piotrowicz <dario@cloudflare.com>
1 parent e3beb2e commit c7d0d18

6 files changed

Lines changed: 152 additions & 21 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/format-errors": patch
3+
---
4+
5+
Fix error formatting to reliably return fallback responses on failure
6+
7+
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.

packages/format-errors/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"check:lint": "eslint . --max-warnings=0 --cache",
88
"check:type": "tsc",
99
"deploy": "wrangler deploy",
10-
"start": "wrangler dev"
10+
"start": "wrangler dev",
11+
"test:ci": "vitest run"
1112
},
1213
"devDependencies": {
1314
"@cloudflare/eslint-config-shared": "workspace:*",
@@ -17,6 +18,7 @@
1718
"promjs": "^0.4.2",
1819
"toucan-js": "4.0.0",
1920
"tsconfig": "*",
21+
"vitest": "catalog:default",
2022
"wrangler": "workspace:*",
2123
"zod": "^3.22.3"
2224
},
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { Payload } from "../index";
3+
4+
// Mock external dependencies that can't be resolved by Vite in test env
5+
vi.mock("promjs", () => {
6+
const mockCounter = { inc: vi.fn() };
7+
const mockRegistry = {
8+
create: vi.fn(() => mockCounter),
9+
metrics: vi.fn(() => ""),
10+
};
11+
return { default: () => mockRegistry };
12+
});
13+
14+
vi.mock("toucan-js", () => {
15+
return {
16+
Toucan: vi.fn().mockImplementation(() => ({
17+
captureException: vi.fn(),
18+
})),
19+
};
20+
});
21+
22+
describe("handlePrettyErrorRequest", () => {
23+
it("should propagate async rejections to callers", async () => {
24+
// Mock Youch to throw asynchronously
25+
vi.doMock("../Youch", () => {
26+
return {
27+
default: vi.fn().mockImplementation(() => ({
28+
addLink: vi.fn(),
29+
toHTML: vi.fn().mockRejectedValue(new Error("Youch async error")),
30+
})),
31+
};
32+
});
33+
34+
// handlePrettyErrorRequest is async — if it's not awaited, async
35+
// rejections (e.g. from Youch.toHTML()) won't be caught by a
36+
// surrounding try/catch, leading to unhandled promise rejections.
37+
// This test verifies that the function properly rejects so that
38+
// callers who `await` it can catch the error.
39+
const { handlePrettyErrorRequest } = await import("../index");
40+
41+
const payload: Payload = {
42+
url: "https://example.com",
43+
method: "GET",
44+
headers: { "content-type": "text/html" },
45+
error: {
46+
message: "Test error",
47+
name: "Error",
48+
stack: "Error: Test error\n at test:1:1",
49+
},
50+
};
51+
52+
await expect(handlePrettyErrorRequest(payload)).rejects.toThrow(
53+
"Youch async error"
54+
);
55+
});
56+
});
57+
58+
describe("reviveError", () => {
59+
it("should revive a plain Error", async () => {
60+
const { reviveError } = await import("../index");
61+
const error = reviveError({
62+
message: "test",
63+
name: "Error",
64+
stack: "Error: test\n at foo:1:1",
65+
});
66+
expect(error).toBeInstanceOf(Error);
67+
expect(error.message).toBe("test");
68+
expect(error.name).toBe("Error");
69+
expect(error.stack).toBe("Error: test\n at foo:1:1");
70+
});
71+
72+
it("should revive a TypeError", async () => {
73+
const { reviveError } = await import("../index");
74+
const error = reviveError({
75+
message: "x is not a function",
76+
name: "TypeError",
77+
});
78+
expect(error).toBeInstanceOf(TypeError);
79+
expect(error.message).toBe("x is not a function");
80+
});
81+
82+
it("should revive an error with a cause", async () => {
83+
const { reviveError } = await import("../index");
84+
const error = reviveError({
85+
message: "outer",
86+
name: "Error",
87+
cause: {
88+
message: "inner",
89+
name: "RangeError",
90+
},
91+
});
92+
expect(error).toBeInstanceOf(Error);
93+
expect(error.message).toBe("outer");
94+
expect(error.cause).toBeInstanceOf(RangeError);
95+
expect((error.cause as Error).message).toBe("inner");
96+
});
97+
98+
it("should fall back to Error for unknown error names", async () => {
99+
const { reviveError } = await import("../index");
100+
const error = reviveError({
101+
message: "custom",
102+
name: "CustomError",
103+
});
104+
expect(error).toBeInstanceOf(Error);
105+
expect(error.name).toBe("CustomError");
106+
});
107+
});

packages/format-errors/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export default {
167167
}
168168

169169
try {
170-
return handlePrettyErrorRequest(payload);
170+
return await handlePrettyErrorRequest(payload);
171171
} catch (e) {
172172
sentry.captureException(e);
173173
const errorCounter = registry.create(
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import { defineProject, mergeConfig } from "vitest/config";
4+
import configShared from "../../vitest.shared";
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
8+
export default mergeConfig(
9+
configShared,
10+
defineProject({
11+
resolve: {
12+
alias: {
13+
// promjs has a broken package.json (main points to lib/index.js
14+
// which doesn't exist in the installed package). Alias it to the
15+
// actual entry point so Vite can resolve it in tests.
16+
promjs: path.resolve(__dirname, "node_modules/promjs/index.js"),
17+
},
18+
},
19+
test: {
20+
include: ["src/__tests__/**/*.{test,spec}.{ts,js}"],
21+
},
22+
})
23+
);

pnpm-lock.yaml

Lines changed: 11 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)