diff --git a/docs-internal/engine/napi-bridge.md b/docs-internal/engine/napi-bridge.md index 1bbda8b1bb..0fc19c9cd8 100644 --- a/docs-internal/engine/napi-bridge.md +++ b/docs-internal/engine/napi-bridge.md @@ -25,7 +25,7 @@ Rules for `rivetkit-typescript/packages/rivetkit-napi/`. The bridge is pure plum ## Payload + error conventions - `#[napi(object)]` bridge payloads stay plain-data only. If TypeScript needs to cancel native work, use primitives or JS-side polling instead of trying to pass a `#[napi]` class instance through an object field. -- N-API structured errors cross the JS<->Rust boundary by prefix-encoding `{ group, code, message, metadata }` into `napi::Error.reason`, then normalizing that prefix back into a `RivetError` on the other side. +- N-API structured errors cross the JS<->Rust boundary by prefix-encoding `{ group, code, message, metadata, rayId }` into `napi::Error.reason`, then normalizing that prefix back into a `RivetError` on the other side. - N-API bridge debug logs use stable `kind` plus compact payload summaries, never raw buffers or full request bodies. ## Receive-loop state lifecycle diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs index 437e2e8b6f..d4442a6d9a 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs @@ -377,6 +377,7 @@ impl ActorContext { message: Some(message), public_: Some(true), status_code: Some(401), + ray_id: None, })) }) } diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs index 0787420054..2681b0abb0 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs @@ -269,6 +269,8 @@ struct BridgeRivetErrorPayload { code: String, message: String, metadata: Option, + #[serde(rename = "rayId")] + ray_id: Option, #[serde(rename = "public")] public_: Option, #[serde(rename = "statusCode")] @@ -281,6 +283,7 @@ pub(crate) struct BridgeRivetErrorContext { pub message: Option, pub public_: Option, pub status_code: Option, + pub ray_id: Option, } impl std::fmt::Display for BridgeRivetErrorContext { @@ -1010,6 +1013,7 @@ fn parse_bridge_rivet_error(reason: &str) -> Option { message: Some(message), public_: payload.public_, status_code: payload.status_code, + ray_id: payload.ray_id, })) } diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs index 51e164eab6..d4df87d2bb 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/lib.rs @@ -97,6 +97,7 @@ fn anyhow_to_bridge_rivet_error_payload(error: anyhow::Error) -> serde_json::Val "code": error.code(), "message": error.message(), "metadata": error.metadata(), + "rayId": bridge_context.and_then(|context| context.ray_id.as_deref()), "public": public_, "statusCode": status_code, "actor": error.actor(), diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs index ad8d18aea4..67874733a7 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/actor_factory.rs @@ -66,6 +66,7 @@ mod moved_tests { "code": "same_code", "message": "same message", "metadata": { "count": 1 }, + "rayId": "ray-123", }) ); @@ -76,6 +77,12 @@ mod moved_tests { assert!(transport_error(&first).schema().is_none()); assert_eq!(transport_error(&second).group(), "actor"); assert_eq!(transport_error(&second).code(), "same_code"); + + let payload = crate::anyhow_to_bridge_rivet_error_payload(first); + assert_eq!( + payload.get("rayId").and_then(|value| value.as_str()), + Some("ray-123") + ); } #[test] diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts index 71ef418821..8afa83b649 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/errors.ts @@ -12,6 +12,8 @@ export interface RivetErrorOptions extends ErrorOptions { public?: boolean; /** Metadata associated with this error. */ metadata?: unknown; + /** Request identifier used to correlate this error with engine logs. */ + rayId?: string; /** Explicit HTTP status override for router responses. */ statusCode?: number; /** Actor context associated with this error. */ @@ -31,6 +33,7 @@ export interface RivetErrorLike { code: string; message: string; metadata?: unknown; + rayId?: string; public?: boolean; statusCode?: number; actor?: ActorSpecifier; @@ -59,6 +62,7 @@ function looksLikeRivetErrorOptions( value !== null && ("public" in value || "metadata" in value || + "rayId" in value || "statusCode" in value || "actor" in value || "cause" in value) @@ -94,6 +98,9 @@ export function isRivetErrorLike( typeof error.code === "string" && "message" in error && typeof error.message === "string" && + (!("rayId" in error) || + error.rayId === undefined || + typeof error.rayId === "string") && (!("__type" in error) || isTypedErrorTag(error.__type)) ); } @@ -128,6 +135,7 @@ export class RivetError extends Error { public public: boolean; public metadata?: unknown; + public readonly rayId?: string; public statusCode: number; public actor?: ActorSpecifier; public readonly group: string; @@ -161,6 +169,7 @@ export class RivetError extends Error { this.code = code; this.public = normalized.public ?? false; this.metadata = normalized.metadata; + this.rayId = normalized.rayId; this.statusCode = normalized.statusCode ?? (this.public ? 400 : 500); this.actor = normalized.actor; } @@ -205,6 +214,7 @@ export function toRivetError( public: error.public, statusCode: error.statusCode, metadata: error.metadata, + rayId: error.rayId, actor: error.actor, cause: error instanceof Error ? error.cause : undefined, }); @@ -218,6 +228,7 @@ export function toRivetError( public: fallback?.public, statusCode: fallback?.statusCode, metadata: fallback?.metadata, + rayId: fallback?.rayId, actor: fallback?.actor, cause: error instanceof Error ? error : undefined, }, @@ -230,6 +241,7 @@ export function encodeBridgeRivetError(error: RivetErrorLike): string { code: error.code, message: error.message, metadata: error.metadata, + rayId: error.rayId, public: error.public, statusCode: error.statusCode, actor: error.actor, @@ -272,6 +284,7 @@ export function decodeBridgeRivetError(value: string): RivetError | undefined { return new RivetError(payload.group, payload.code, payload.message, { metadata: payload.metadata, + rayId: payload.rayId, public: payload.public, statusCode: payload.statusCode, actor: payload.actor ?? undefined, @@ -303,6 +316,7 @@ export function internalError( public: options?.public, statusCode: options?.statusCode, metadata: options?.metadata, + rayId: options?.rayId, actor: options?.actor, cause: options?.cause, }, diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts index adbb7b8af4..70f6ad17aa 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts @@ -874,7 +874,7 @@ export class ActorConnRaw { const parsed = parseWebSocketCloseReason(reason); if (parsed) { - const { group, code } = parsed; + const { group, code, rayId } = parsed; if (this.#shouldReconnectForStaleActor(group, code)) { this.#clearResolvedActorIdentity(); @@ -883,7 +883,7 @@ export class ActorConnRaw { group, code, `Connection closed: ${reason}`, - undefined, + { rayId }, ), ); return; @@ -897,6 +897,7 @@ export class ActorConnRaw { this.#actorId, this.#actorResolutionState, this.#driver, + rayId, ); if (schedulingError) { error = schedulingError; @@ -905,7 +906,7 @@ export class ActorConnRaw { group, code, `Connection closed: ${reason}`, - undefined, + { rayId }, ); } } else { @@ -913,7 +914,7 @@ export class ActorConnRaw { group, code, `Connection closed: ${reason}`, - undefined, + { rayId }, ); } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts index b911baa39c..37bc4a9a34 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts @@ -166,7 +166,7 @@ export class ActorHandleRaw { }, }).send(name, body, options as any); } catch (err) { - const { group, code, message, metadata, actor } = + const { group, code, message, metadata, rayId, actor } = deconstructError(err, true); if ( @@ -189,6 +189,7 @@ export class ActorHandleRaw { actorId, attempt, maxAttempts, + rayId, ) ) { useQueryTarget = true; @@ -227,6 +228,7 @@ export class ActorHandleRaw { throw new ActorError(group, code, message, { metadata, + rayId, actor, }); } @@ -357,7 +359,7 @@ export class ActorHandleRaw { } return output; } catch (err) { - const { group, code, message, metadata, actor } = + const { group, code, message, metadata, rayId, actor } = deconstructError(err, true); if ( @@ -367,6 +369,7 @@ export class ActorHandleRaw { actorId, attempt, maxAttempts, + rayId, ) ) { useQueryTarget = true; @@ -398,7 +401,7 @@ export class ActorHandleRaw { "actor", "not_found", "The actor does not exist or was destroyed.", - { metadata, actor }, + { metadata, rayId, actor }, ); } @@ -419,7 +422,11 @@ export class ActorHandleRaw { continue; } - throw new ActorError(group, code, message, { metadata, actor }); + throw new ActorError(group, code, message, { + metadata, + rayId, + actor, + }); } } @@ -514,6 +521,7 @@ export class ActorHandleRaw { actorId: string | undefined, attempt: number, maxAttempts: number, + rayId?: string, ): Promise { if ( !isDynamicActorQuery(this.#actorResolutionState) || @@ -530,6 +538,7 @@ export class ActorHandleRaw { actorId, this.#actorResolutionState, this.#driver, + rayId, ); if (schedulingError) { throw schedulingError; @@ -679,7 +688,7 @@ export class ActorHandleRaw { } return response; } catch (err) { - const { group, code, message, metadata, actor } = + const { group, code, message, metadata, rayId, actor } = deconstructError(err, true); if ( @@ -689,6 +698,7 @@ export class ActorHandleRaw { actorId, attempt, maxAttempts, + rayId, ) ) { useQueryTarget = true; @@ -725,7 +735,11 @@ export class ActorHandleRaw { continue; } - throw new ActorError(group, code, message, { metadata, actor }); + throw new ActorError(group, code, message, { + metadata, + rayId, + actor, + }); } } @@ -750,7 +764,7 @@ export class ActorHandleRaw { return null; } - const { group, code } = error; + const { group, code, rayId } = error; if ( await this.#shouldRetrySchedulingError( @@ -759,6 +773,7 @@ export class ActorHandleRaw { actorId, attempt, maxAttempts, + rayId, ) ) { return { @@ -802,6 +817,7 @@ export class ActorHandleRaw { code: string; message: string; metadata?: unknown; + rayId?: string; actor?: ActorSpecifier; } | null> { if (response.ok) { @@ -814,7 +830,7 @@ export class ActorHandleRaw { : this.#encoding; try { - return deserializeWithEncoding< + const error = deserializeWithEncoding< protocol.HttpResponseError, HttpResponseErrorJson, { @@ -822,6 +838,7 @@ export class ActorHandleRaw { code: string; message: string; metadata?: unknown; + rayId?: string; actor?: ActorSpecifier; } >( @@ -854,6 +871,10 @@ export class ActorHandleRaw { : undefined, }), ); + return { + ...error, + rayId: response.headers.get("x-rivet-ray-id") ?? undefined, + }; } catch { return null; } @@ -927,7 +948,9 @@ export class ActorHandleRaw { "actor", "reload_failed", `reload failed with status ${response.status}: ${body}`, - {}, + { + rayId: response.headers.get("x-rivet-ray-id") ?? undefined, + }, ); } } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts index c9ebd90456..5a659a30b3 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-query.ts @@ -67,6 +67,7 @@ export async function checkForSchedulingError( actorId: string, query: ActorQuery, driver: EngineControlClient, + rayId?: string, ): Promise { const name = getActorNameFromQuery(query); @@ -79,7 +80,13 @@ export async function checkForSchedulingError( actorId, error: actor.error, }); - return actorSchedulingError(group, code, actorId, actor.error); + return actorSchedulingError( + group, + code, + actorId, + actor.error, + rayId, + ); } } catch (err) { logger().warn({ diff --git a/rivetkit-typescript/packages/rivetkit/src/client/errors.ts b/rivetkit-typescript/packages/rivetkit/src/client/errors.ts index a72952086f..1303de57c7 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/errors.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/errors.ts @@ -51,12 +51,13 @@ export function actorSchedulingError( code: string, actorId: string, details: unknown, + rayId?: string, ): RivetError { return new RivetError( group, code, `Actor failed to start (${actorId}): ${JSON.stringify(details)}`, - { metadata: { actorId, details } }, + { metadata: { actorId, details }, rayId }, ); } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts b/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts index cbaeb6e113..eb980e54ea 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/raw-utils.ts @@ -98,11 +98,9 @@ export async function rawHttpFetch( return driver.sendRequest(target, proxyRequest, options); } catch (err) { // Standardize to ClientActorError instead of the native backend error - const { group, code, message, metadata, actor } = deconstructError( - err, - true, - ); - throw new ActorError(group, code, message, { metadata, actor }); + const { group, code, message, metadata, rayId, actor } = + deconstructError(err, true); + throw new ActorError(group, code, message, { metadata, rayId, actor }); } } diff --git a/rivetkit-typescript/packages/rivetkit/src/client/utils.ts b/rivetkit-typescript/packages/rivetkit/src/client/utils.ts index 3d1da3fbd2..2e79b18235 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/utils.ts @@ -215,6 +215,7 @@ export async function sendHttpRequest< code: responseData.code, message: responseData.message, metadata: responseData.metadata, + rayId, actorId: responseData.actor?.actorId, generation: responseData.actor?.generation, actorKey: responseData.actor?.key, @@ -226,6 +227,7 @@ export async function sendHttpRequest< responseData.message, { metadata: responseData.metadata, + rayId: rayId ?? undefined, actor: responseData.actor, }, ); diff --git a/rivetkit-typescript/packages/rivetkit/src/common/utils.ts b/rivetkit-typescript/packages/rivetkit/src/common/utils.ts index edb2494af5..f8ac56b2b9 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/utils.ts @@ -44,6 +44,7 @@ export interface DeconstructedError { code: string; message: string; metadata?: unknown; + rayId?: string; actor?: errors.ActorSpecifier; } @@ -80,6 +81,7 @@ export function deconstructError( let code: string; let message: string; let metadata: unknown; + let rayId: string | undefined; let actor: errors.ActorSpecifier | undefined; // Structured errors from core or from pre-built `RivetError` instances are canonical. // Only unstructured errors go through the classifier below. @@ -96,6 +98,7 @@ export function deconstructError( code = error.code; message = error.message; metadata = error.metadata; + rayId = error.rayId; actor = error.actor; } else if (errors.ActorError.isActorError(error) && error.public) { // Check if error has statusCode (could be ActorError instance or DeconstructedError) @@ -107,6 +110,7 @@ export function deconstructError( code = error.code; message = getErrorMessage(error); metadata = error.metadata; + rayId = error.rayId; actor = error.actor; } else if (exposeInternalError) { if (errors.ActorError.isActorError(error)) { @@ -116,6 +120,7 @@ export function deconstructError( code = error.code; message = getErrorMessage(error); metadata = error.metadata; + rayId = error.rayId; actor = error.actor; } else { statusCode = 500; @@ -146,6 +151,7 @@ export function deconstructError( code, message, metadata, + rayId, actor, }; } diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index cc9a3541ac..f4a092f37a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -749,6 +749,7 @@ function encodeNativeCallbackError(error: unknown): Error { group: structuredError.group, code: structuredError.code, metadata: structuredError.metadata, + rayId: structuredError.rayId, }); } diff --git a/rivetkit-typescript/packages/rivetkit/tests/rivet-error.test.ts b/rivetkit-typescript/packages/rivetkit/tests/rivet-error.test.ts index ed964b6be0..de01b8d84b 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/rivet-error.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/rivet-error.test.ts @@ -5,12 +5,15 @@ import { RivetError, toRivetError, } from "../src/actor/errors"; +import { createClientWithDriver } from "../src/client/client"; import { deconstructError } from "../src/common/utils"; +import type { EngineControlClient } from "../src/engine-client/driver"; describe("RivetError bridge helpers", () => { test("round trips structured bridge payloads", () => { const error = new RivetError("user", "boom", "typed failure", { metadata: { source: "native" }, + rayId: "ray-123", public: true, actor: { actorId: "actor-123", @@ -27,6 +30,7 @@ describe("RivetError bridge helpers", () => { code: "boom", message: "typed failure", metadata: { source: "native" }, + rayId: "ray-123", actor: { actorId: "actor-123", generation: 7, @@ -57,6 +61,7 @@ describe("RivetError bridge helpers", () => { public: true, statusCode: 408, metadata: { source: "core" }, + rayId: "ray-456", }, ); @@ -69,9 +74,23 @@ describe("RivetError bridge helpers", () => { code: "action_timed_out", message: "Action timed out", metadata: { source: "core" }, + rayId: "ray-456", }); }); + test("keeps ray ID separate from application metadata", () => { + const metadata = { rayId: "application-value", source: "user" }; + const error = toRivetError( + new RivetError("user", "boom", "typed failure", { + metadata, + rayId: "engine-value", + }), + ); + + expect(error.rayId).toBe("engine-value"); + expect(error.metadata).toBe(metadata); + }); + test("does not treat plain objects as structured errors", () => { const result = deconstructError({ group: "foo", @@ -103,3 +122,45 @@ describe("RivetError bridge helpers", () => { }); }); }); + +describe("RivetError HTTP diagnostics", () => { + test("exposes response ray ID from an actor action to the user", async () => { + const metadata = { source: "engine" }; + const driver = { + sendRequest: async () => + new Response( + JSON.stringify({ + group: "core", + code: "internal_error", + message: "An internal error occurred", + metadata, + }), + { + status: 500, + headers: { + "content-type": "application/json", + "x-rivet-ray-id": "ray-http-123", + }, + }, + ), + } as EngineControlClient; + const client = createClientWithDriver(driver, { encoding: "json" }); + const handle = client.getForId("test-actor", "actor-123"); + let thrown: unknown; + + try { + await handle.action({ name: "fail", args: [] }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(RivetError); + expect(thrown).toMatchObject({ + group: "core", + code: "internal_error", + message: "An internal error occurred", + metadata, + rayId: "ray-http-123", + }); + }); +});