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
10 changes: 10 additions & 0 deletions .changeset/preserve-binding-error-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"miniflare": patch
"wrangler": patch
---

Preserve binding error codes and names in local development

Errors from remote bindings and from Node.js binding proxies (`getPlatformProxy()` and `Miniflare#getBindings()`) now keep their machine-readable properties, such as Artifacts' `code` and `numericCode`. Previously these properties were dropped and the error name was folded into the message (for example `"ArtifactsError: Repository not found"`), so code could only branch on error text during local development.

Node.js callers also keep custom error names such as `ArtifactsError`. Errors from remote bindings received inside a Worker keep their properties but are still named `Error`.
8 changes: 7 additions & 1 deletion packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,13 @@ export function getGlobalServices({
worker: {
modules: [{ name: "entry.worker.js", esModule: SCRIPT_ENTRY() }],
compatibilityDate: "2025-03-17",
compatibilityFlags: ["nodejs_compat", "service_binding_extra_handlers"],
compatibilityFlags: [
"nodejs_compat",
"service_binding_extra_handlers",
// Keep custom error names and properties (for example Artifacts
// `code`) when binding errors reach `ProxyServer` for Node callers.
"enhanced_error_serialization",
],
bindings: serviceEntryBindings,
durableObjectNamespaces: [
{
Expand Down
14 changes: 9 additions & 5 deletions packages/miniflare/src/plugins/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,15 @@ export function remoteProxyClientWorker(
) {
return {
compatibilityDate: "2025-01-01",
// Raw TCP bindings (e.g. VPC networks) tunnel `binding.connect()` traffic
// through this worker's inbound `connect` handler, which requires the
// `experimental` compatibility flag (workerd#6059). Other bindings only
// proxy HTTP/JSRPC and must not opt in.
...(options?.rawTcp ? { compatibilityFlags: ["experimental"] } : {}),
// `enhanced_error_serialization` keeps custom error names and properties
// (for example Artifacts `code` and `numericCode`) when this worker rethrows
// remote errors to the user Worker. Raw TCP bindings (e.g. VPC networks)
// tunnel `binding.connect()` traffic through this worker's inbound `connect`
// handler, which also requires the `experimental` compatibility flag
// (workerd#6059). Other bindings only proxy HTTP/JSRPC and must not opt in.
compatibilityFlags: options?.rawTcp
? ["experimental", "enhanced_error_serialization"]
: ["enhanced_error_serialization"],
modules: [
{
name: "index.worker.js",
Expand Down
64 changes: 61 additions & 3 deletions packages/miniflare/src/workers/core/devalue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,56 @@ export const structuredSerializableReducers: ReducersRevivers = {
Error(value) {
for (const ctor of ALLOWED_ERROR_CONSTRUCTORS) {
if (value instanceof ctor && value.name === ctor.name) {
return [value.name, value.message, value.stack, value.cause];
return serializeError(value, value.name);
}
}
if (value instanceof Error) {
return ["Error", value.message, value.stack, value.cause];
return serializeError(value, "Error");
}
},
};

const RESERVED_ERROR_KEYS = new Set(["name", "message", "stack", "cause"]);

function isPrimitiveErrorProperty(
value: unknown
): value is string | number | boolean | null {
return (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}

// Errors from bindings carry machine-readable properties such as Artifacts'
// `code` and `numericCode`, and custom names such as `ArtifactsError`. Preserve
// primitive own properties and the original name so Node callers can branch on
// them. Like workerd's `enhanced_error_serialization`, this includes
// non-enumerable properties. Non-primitive properties are skipped so they
// cannot make an error unserialisable.
function serializeError(error: Error, constructorName: string): unknown[] {
const properties: Record<string, string | number | boolean | null> = {};
for (const key of Object.getOwnPropertyNames(error)) {
Comment thread
edmundhung marked this conversation as resolved.
const property = (error as unknown as Record<string, unknown>)[key];
if (!RESERVED_ERROR_KEYS.has(key) && isPrimitiveErrorProperty(property)) {
properties[key] = property;
}
}
if (typeof error.name === "string" && error.name !== constructorName) {
properties.name = error.name;
}
const serialized: unknown[] = [
constructorName,
error.message,
error.stack,
error.cause,
];
if (Object.keys(properties).length > 0) {
serialized.push(properties);
}
return serialized;
}
export const structuredSerializableRevivers: ReducersRevivers = {
ArrayBuffer(value) {
assert(Array.isArray(value));
Expand Down Expand Up @@ -130,7 +172,7 @@ export const structuredSerializableRevivers: ReducersRevivers = {
},
Error(value) {
assert(Array.isArray(value));
const [name, message, stack, cause] = value as unknown[];
const [name, message, stack, cause, properties] = value as unknown[];
assert(typeof name === "string");
assert(typeof message === "string");
assert(stack === undefined || typeof stack === "string");
Expand All @@ -140,6 +182,22 @@ export const structuredSerializableRevivers: ReducersRevivers = {
assert(ALLOWED_ERROR_CONSTRUCTORS.includes(ctor));
const error = new ctor(message, { cause });
error.stack = stack;
if (properties !== undefined) {
assert(typeof properties === "object" && properties !== null);
for (const [key, property] of Object.entries(properties)) {
if (
(key === "name" || !RESERVED_ERROR_KEYS.has(key)) &&
isPrimitiveErrorProperty(property)
) {
Object.defineProperty(error, key, {
value: property,
writable: true,
enumerable: key !== "name",
configurable: true,
});
}
}
}
return error;
},
};
Expand Down
66 changes: 66 additions & 0 deletions packages/miniflare/test/plugins/core/proxy/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,72 @@ describe("ProxyClient", () => {
expect(event.data).toBe("echo:hello");
});

test("preserves error codes and names thrown by RPC bindings", async ({
expect,
}) => {
const mf = new Miniflare({
workers: [
{
config: {
name: "",
compatibilityDate: "2025-05-01",
env: {
BINDING: {
type: "worker",
worker: "binding",
exportName: "BindingEntrypoint",
},
},
},
legacy: { serviceWorkerScript: nullScript },
},
{
config: {
name: "binding",
compatibilityDate: "2025-05-01",
compatibilityFlags: ["enhanced_error_serialization"],
manifest: singleModuleManifest(`
import { WorkerEntrypoint } from "cloudflare:workers";
class BindingError extends Error {
name = "BindingError";
constructor(message, code, numericCode) {
super(message);
this.code = code;
this.numericCode = numericCode;
}
}
export class BindingEntrypoint extends WorkerEntrypoint {
get(name) {
throw new BindingError("Repository not found: " + name + ".", "NOT_FOUND", 10200);
}
}
export default { fetch() { return new Response(null, { status: 404 }); } };
`),
},
},
],
});
useDispose(mf);

const { BINDING } = await mf.getBindings<{
BINDING: { get(name: string): Promise<never> };
}>();

let error: unknown;
try {
await BINDING.get("missing");
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({
name: "BindingError",
message: "Repository not found: missing.",
code: "NOT_FOUND",
numericCode: 10200,
});
});

test("preserves original URL for service binding fetch", async ({
expect,
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,7 @@ describe("remoteProxyClientWorker: raw TCP opt-in", () => {
const worker = remoteProxyClientWorker();
expect(
(worker as { compatibilityFlags?: string[] }).compatibilityFlags
).toBeUndefined();
).toEqual(["enhanced_error_serialization"]);
});

test("enables the experimental flag when rawTcp is set", ({ expect }) => {
Expand All @@ -743,7 +743,7 @@ describe("remoteProxyClientWorker: raw TCP opt-in", () => {
});
expect(
(worker as { compatibilityFlags?: string[] }).compatibilityFlags
).toEqual(["experimental"]);
).toEqual(["experimental", "enhanced_error_serialization"]);
});
});

Expand Down Expand Up @@ -771,7 +771,10 @@ describe("VPC_SERVICES plugin: raw TCP opt-in", () => {
worker?: { compatibilityFlags?: string[] };
}>;
expect(serviceList).toHaveLength(1);
expect(serviceList[0].worker?.compatibilityFlags).toEqual(["experimental"]);
expect(serviceList[0].worker?.compatibilityFlags).toEqual([
"experimental",
"enhanced_error_serialization",
]);
});
});

Expand Down
61 changes: 61 additions & 0 deletions packages/miniflare/test/workers/core/serialize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,64 @@ test("does not treat an object with a forged Headers tag as Headers", ({
/symbolic keys/
);
});

test("serialize Error preserves primitive own properties and custom names", ({
expect,
}) => {
const input = Object.assign(new Error("Repository not found: missing."), {
code: "NOT_FOUND",
retryable: false,
details: null,
});
Object.defineProperty(input, "numericCode", { value: 10200 });
input.name = "ArtifactsError";

const serialized = stringify(input, structuredSerializableReducers);
const deserialized = parse(serialized, structuredSerializableRevivers);

expect(deserialized).toBeInstanceOf(Error);
expect(deserialized).toMatchObject({
name: "ArtifactsError",
message: "Repository not found: missing.",
code: "NOT_FOUND",
numericCode: 10200,
retryable: false,
details: null,
});
expect(Object.keys(deserialized)).not.toContain("name");
});

test("serialize Error keeps built-in constructors and skips non-primitive properties", ({
expect,
}) => {
const input = Object.assign(new TypeError("bad input"), {
code: "INVALID_INPUT",
callback: () => {},
nested: { value: 1 },
});

const serialized = stringify(input, structuredSerializableReducers);
const deserialized = parse(serialized, structuredSerializableRevivers);

expect(deserialized).toBeInstanceOf(TypeError);
expect(deserialized.name).toBe("TypeError");
expect(deserialized.code).toBe("INVALID_INPUT");
expect(deserialized).not.toHaveProperty("callback");
expect(deserialized).not.toHaveProperty("nested");
});

test("serialize Error without extra properties keeps the existing encoding", ({
expect,
}) => {
const input = new RangeError("out of range");
input.stack = "stack";

const serialized = stringify(input, structuredSerializableReducers);

expect(serialized).toBe(
'[["Error",1],[2,3,4,-1],"RangeError","out of range","stack"]'
);
const deserialized = parse(serialized, structuredSerializableRevivers);
expect(deserialized).toBeInstanceOf(RangeError);
expect(deserialized.message).toBe("out of range");
});
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ export async function startRemoteProxySession(
cliDisplayName: options.cliDisplayName,
entrypointSource: remoteBindingsWorkerSource,
compatibilityDate: "2025-04-28",
compatibilityFlags: [],
// Keep custom error names and properties (for example Artifacts `code` and
// `numericCode`) when errors cross from the remote binding into this worker.
compatibilityFlags: ["enhanced_error_serialization"],
complianceRegion: options.complianceRegion,
bindings: rawBindings,
auth: getRemoteBindingsAuthHook(
Expand Down
Loading