Skip to content

Commit 6987ecc

Browse files
committed
fix(rivetkit): add lifecycle error retry and gateway HTTP routing
1 parent 0f76287 commit 6987ecc

7 files changed

Lines changed: 406 additions & 16 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,30 @@ export function actorStopping(identifier?: string): RivetError {
367367
);
368368
}
369369

370+
export interface ActorRestartingOptions {
371+
phase?: "stopping" | "sleeping" | "waking" | "runner_shutdown";
372+
retryAfterMs?: number;
373+
}
374+
375+
export function actorRestarting(opts?: ActorRestartingOptions): RivetError {
376+
return new RivetError(
377+
"actor",
378+
"restarting",
379+
"Actor is restarting. Retry the request.",
380+
{
381+
public: true,
382+
statusCode: 503,
383+
metadata: {
384+
retryable: true,
385+
...(opts?.phase ? { phase: opts.phase } : {}),
386+
...(opts?.retryAfterMs !== undefined
387+
? { retryAfterMs: opts.retryAfterMs }
388+
: {}),
389+
},
390+
},
391+
);
392+
}
393+
370394
export function forbiddenError(): RivetError {
371395
return new RivetError("auth", "forbidden", "Forbidden", {
372396
public: true,

rivetkit-typescript/packages/rivetkit/src/actor/instance/mod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,7 @@ export class ActorInstance<
919919
async restartRunHandler(): Promise<void> {
920920
this.assertReady();
921921
if (this.#stopCalled)
922-
throw new errors.InternalError("Actor is stopping");
922+
throw errors.actorRestarting({ phase: "stopping" });
923923
if (this.#runHandlerActive && this.#runPromise) {
924924
await this.#runPromise;
925925
}

rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import invariant from "invariant";
2-
import pRetry from "p-retry";
2+
import pRetry, { AbortError } from "p-retry";
33
import type { AnyActorDefinition } from "@/actor/definition";
44
import { PATH_CONNECT } from "@/common/actor-router-consts";
55
import type * as protocol from "@/common/client-protocol";
@@ -44,6 +44,7 @@ import {
4444
} from "./actor-query";
4545
import { ACTOR_CONNS_SYMBOL, type ClientRaw } from "./client";
4646
import * as errors from "./errors";
47+
import { isRetryableLifecycleReconnectSignal } from "./lifecycle-errors";
4748
import { logger } from "./log";
4849
import {
4950
createQueueSender,
@@ -441,7 +442,11 @@ export class ActorConnRaw {
441442
// Cancel retry if aborted
442443
signal: this.#abortController.signal,
443444
}).catch((err) => {
444-
if ((err as Error).name === "AbortError") {
445+
if (
446+
err instanceof AbortError ||
447+
(err as Error).name === "AbortError" ||
448+
!this.#shouldRetryConnectionOpenError(err)
449+
) {
445450
logger().info({ msg: "connection retry aborted" });
446451
} else {
447452
logger().error({
@@ -468,11 +473,51 @@ export class ActorConnRaw {
468473

469474
// Wait for result
470475
await this.#onOpenPromise.promise;
476+
} catch (error) {
477+
if (this.#shouldRetryConnectionOpenError(error)) {
478+
throw error;
479+
}
480+
481+
const actorError =
482+
error instanceof errors.ActorError
483+
? error
484+
: new errors.ActorError(
485+
"client",
486+
"connection_open_failed",
487+
`Failed to open connection: ${stringifyError(error)}`,
488+
{ error: stringifyError(error) },
489+
);
490+
491+
this.#clearQueuedMessages();
492+
this.#rejectPendingPromises(actorError, false);
493+
this.#dispatchActorError(actorError);
494+
this.#setConnStatus("idle");
495+
496+
throw new AbortError(
497+
error instanceof Error
498+
? error
499+
: new Error(stringifyError(error)),
500+
);
471501
} finally {
472502
this.#onOpenPromise = undefined;
473503
}
474504
}
475505

506+
#shouldRetryConnectionOpenError(error: unknown): boolean {
507+
if (error instanceof errors.ActorConnDisposed) {
508+
return false;
509+
}
510+
511+
if (
512+
error instanceof errors.ActorError &&
513+
this.#shouldReconnectForStaleActor(error.group, error.code)
514+
) {
515+
return true;
516+
}
517+
518+
return isRetryableLifecycleReconnectSignal(error);
519+
}
520+
476521
#clearQueuedMessages() {
477522
if (this.#messageQueue.length === 0) return;
478523

rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from "./actor-query";
4040
import { type ClientRaw, CREATE_ACTOR_CONN_PROXY } from "./client";
4141
import { ActorError, isSchedulingError } from "./errors";
42+
import { retryOnLifecycleBoundary } from "./lifecycle-errors";
4243
import { logger } from "./log";
4344
import {
4445
createQueueSender,
@@ -233,7 +234,12 @@ export class ActorHandleRaw {
233234
`Invalid action call: expected an options object { name, args }, got ${typeof opts}. Use handle.actionName(...args) for the shorthand API.`,
234235
);
235236
}
236-
return (await this.#sendActionNow(opts)) as Response;
237+
const run = async () => (await this.#sendActionNow(opts)) as Response;
238+
if (opts.name === "destroy") {
239+
return await run();
240+
}
241+
242+
return await retryOnLifecycleBoundary(run, { signal: opts.signal });
237243
}
238244

239245
async #sendActionNow(opts: {

0 commit comments

Comments
 (0)