Skip to content

Commit 575812e

Browse files
committed
fix(rivetkit): keep UserErrors intact when their metadata is not JSON
1 parent 375d414 commit 575812e

7 files changed

Lines changed: 86 additions & 9 deletions

File tree

‎rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/error-handling.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export const errorHandlingActor = actor({
2121
});
2222
},
2323

24+
// Action that throws a UserError whose metadata JSON cannot encode
25+
throwBigintMetadataError: () => {
26+
throw new UserError("Card declined", {
27+
code: "card_declined",
28+
metadata: { amountCents: 1999n },
29+
});
30+
},
31+
2432
// Action that throws an internal error
2533
throwInternalError: () => {
2634
throw new Error("This is an internal error");

‎rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/telemetry.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,10 @@ export const telemetryRunConsumerActor = actor({
123123
actions: {},
124124
});
125125

126-
/** Waits for two messages and logs when it sleeps, so a test can send the second one after the sleep. */
126+
/**
127+
* Waits for two messages and logs when it sleeps, so a test can send the second one after the sleep.
128+
* The declined charge carries `bigint` metadata, which JSON cannot encode.
129+
*/
127130
export const workflowTracedActor = actor({
128131
state: { chargeAttempts: 0, wakes: 0 },
129132
db: db(),
@@ -157,6 +160,7 @@ export const workflowTracedActor = actor({
157160
if (c.state.chargeAttempts <= 2) {
158161
throw new UserError("card declined", {
159162
code: "card_declined",
163+
metadata: { amountCents: 1999n },
160164
});
161165
}
162166
},

‎rivetkit-typescript/packages/rivetkit/src/actor/errors.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,21 @@ export function toRivetError(
244244
);
245245
}
246246

247-
export function encodeBridgeRivetError(error: RivetErrorLike): string {
248-
return `${BRIDGE_RIVET_ERROR_PREFIX}${JSON.stringify({
247+
/** A structured error encoded for the bridge to Core. */
248+
export interface EncodedBridgeError {
249+
readonly encoded: string;
250+
/** Set when JSON could not encode the metadata, which was dropped. */
251+
readonly droppedMetadataCause?: unknown;
252+
}
253+
254+
/**
255+
* Encodes a structured error for the bridge to Core. Metadata that JSON cannot
256+
* encode, such as a `bigint`, is dropped so the error itself still crosses.
257+
*/
258+
export function encodeBridgeRivetError(
259+
error: RivetErrorLike,
260+
): EncodedBridgeError {
261+
const payload = {
249262
group: error.group,
250263
code: error.code,
251264
message: error.message,
@@ -254,7 +267,17 @@ export function encodeBridgeRivetError(error: RivetErrorLike): string {
254267
public: error.public,
255268
statusCode: error.statusCode,
256269
actor: error.actor,
257-
})}`;
270+
};
271+
try {
272+
return {
273+
encoded: `${BRIDGE_RIVET_ERROR_PREFIX}${JSON.stringify(payload)}`,
274+
};
275+
} catch (cause) {
276+
return {
277+
encoded: `${BRIDGE_RIVET_ERROR_PREFIX}${JSON.stringify({ ...payload, metadata: undefined })}`,
278+
droppedMetadataCause: cause,
279+
};
280+
}
258281
}
259282

260283
/**
@@ -264,7 +287,7 @@ export function encodeBridgeRivetError(error: RivetErrorLike): string {
264287
*/
265288
export function encodeErrorForBridge(error: unknown): string {
266289
if (isCanonicalStructuredRivetError(error)) {
267-
return encodeBridgeRivetError(error);
290+
return encodeBridgeRivetError(error).encoded;
268291
}
269292
return String(error);
270293
}

‎rivetkit-typescript/packages/rivetkit/src/registry/native.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,19 @@ function encodeNativeCallbackError(error: unknown): Error {
872872
? error
873873
: deconstructError(error, true);
874874

875-
const bridgeError = new Error(encodeBridgeRivetError(structuredError), {
875+
const { encoded, droppedMetadataCause } =
876+
encodeBridgeRivetError(structuredError);
877+
if (droppedMetadataCause !== undefined) {
878+
logger().warn(
879+
{
880+
group: structuredError.group,
881+
code: structuredError.code,
882+
error: String(droppedMetadataCause),
883+
},
884+
"dropped error metadata that JSON cannot encode",
885+
);
886+
}
887+
const bridgeError = new Error(encoded, {
876888
cause: error instanceof Error ? error : undefined,
877889
});
878890
return Object.assign(bridgeError, {

‎rivetkit-typescript/packages/rivetkit/tests/driver/actor-error-handling.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test } from "vitest";
1+
import { describe, expect, test, vi } from "vitest";
22
import {
33
INTERNAL_ERROR_CODE,
44
INTERNAL_ERROR_DESCRIPTION,
@@ -48,6 +48,34 @@ describeDriverMatrix("Actor Error Handling", (driverTestConfig) => {
4848
expect(error.metadata.timestamp).toBeDefined();
4949
}
5050
});
51+
52+
test("returns the UserError without metadata JSON cannot encode and logs the drop", async (c) => {
53+
const { client, getRuntimeOutput } = await setupDriverTest(
54+
c,
55+
driverTestConfig,
56+
);
57+
const handle = client.errorHandlingActor.getOrCreate();
58+
59+
const error = await handle.throwBigintMetadataError().then(
60+
() => undefined,
61+
(error: any) => error,
62+
);
63+
64+
expect(error?.code).toBe("card_declined");
65+
expect(error?.message).toBe("Card declined");
66+
expect(error?.metadata).toBeUndefined();
67+
// The runtime process writes its log through a pipe that can lag the action reply.
68+
await vi.waitFor(() => {
69+
const warning = getRuntimeOutput()
70+
.split("\n")
71+
.find((line) =>
72+
line.includes(
73+
"dropped error metadata that JSON cannot encode",
74+
),
75+
);
76+
expect(warning).toContain("code=card_declined");
77+
});
78+
});
5179
});
5280

5381
describe("Internal Error Handling", () => {

‎rivetkit-typescript/packages/rivetkit/tests/driver/actor-telemetry.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1174,7 +1174,7 @@ describeDriverMatrix(
11741174
expect(line).toContain(`rayId=${approveRayId}`);
11751175
});
11761176

1177-
test("reports each attempt of a retried step under its own run", () => {
1177+
test("reports each attempt of a retried step under its own run, even when the error metadata is not JSON", () => {
11781178
const attempts = named(`${actorName}/charge-card`);
11791179
expect(
11801180
attempts.map((attempt) => [

‎rivetkit-typescript/packages/rivetkit/tests/rivet-error.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ describe("RivetError bridge helpers", () => {
2323
},
2424
});
2525

26-
const decoded = decodeBridgeRivetError(encodeBridgeRivetError(error));
26+
const decoded = decodeBridgeRivetError(
27+
encodeBridgeRivetError(error).encoded,
28+
);
2729

2830
expect(decoded).toBeInstanceOf(RivetError);
2931
expect(decoded).toMatchObject({

0 commit comments

Comments
 (0)