Skip to content

Commit f772382

Browse files
Retry transient Durable Object routing failures
PartyServer now retries transient Durable Object infrastructure errors at the routing boundary so callers do not need to wrap every routeAgentRequest or routePartykitRequest use site themselves. The retry path is scoped to retryable, non-overloaded runtime errors and recreates the DO stub/request per attempt while avoiding rerunning user routing hooks. This also exposes routingRetry configuration, documents the option, and covers retry, opt-out, overload, non-retryable, exhausted, body replay, and onRetry observer edge cases. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 798aae5 commit f772382

4 files changed

Lines changed: 365 additions & 11 deletions

File tree

‎.changeset/tall-lions-retry.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"partyserver": patch
3+
---
4+
5+
Retry transient Durable Object routing errors from `routePartykitRequest` and `getServerByName`, and expose route-level retry configuration.

‎packages/partyserver/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,10 @@ export class MyServer extends Server {
169169

170170
### Utility Methods
171171

172-
- `getServerByName(namespace, name, {locationHint, jurisdiction}): Promise<DurableObjectStub>` - Create a new Server with a specific name. Optionally pass a `locationHint` to specify the [location](https://developers.cloudflare.com/durable-objects/reference/data-location/#provide-a-location-hint) or `jurisdiction` to specify the [jurisdiction](https://developers.cloudflare.com/durable-objects/reference/data-location/#restrict-durable-objects-to-a-jurisdiction) of the server.
172+
- `getServerByName(namespace, name, {locationHint, jurisdiction, routingRetry}): Promise<DurableObjectStub>` - Create a new Server with a specific name. Optionally pass a `locationHint` to specify the [location](https://developers.cloudflare.com/durable-objects/reference/data-location/#provide-a-location-hint), `jurisdiction` to specify the [jurisdiction](https://developers.cloudflare.com/durable-objects/reference/data-location/#restrict-durable-objects-to-a-jurisdiction), or `routingRetry: false` to disable retries for transient Durable Object infrastructure errors.
173173

174-
- `routePartykitRequest(request, env, {locationHint, jurisdiction, prefix = 'parties', onBeforeConnect, onBeforeRequest}): Promise<Response | null>` - Match a request to a server. Takes a URL of the form `/${prefix}/:server/:name` and matches it with any namespace named `:server` (case insensitive) and a server named `:name`. Optionally pass a `locationHint` to specify the [location](https://developers.cloudflare.com/durable-objects/reference/data-location/#provide-a-location-hint) or `jurisdiction` to specify the [jurisdiction](https://developers.cloudflare.com/durable-objects/reference/data-location/#restrict-durable-objects-to-a-jurisdiction) of the server.
174+
- `routePartykitRequest(request, env, {locationHint, jurisdiction, prefix = 'parties', routingRetry, onBeforeConnect, onBeforeRequest}): Promise<Response | null>` - Match a request to a server. Takes a URL of the form `/${prefix}/:server/:name` and matches it with any namespace named `:server` (case insensitive) and a server named `:name`. Optionally pass a `locationHint` to specify the [location](https://developers.cloudflare.com/durable-objects/reference/data-location/#provide-a-location-hint), `jurisdiction` to specify the [jurisdiction](https://developers.cloudflare.com/durable-objects/reference/data-location/#restrict-durable-objects-to-a-jurisdiction), or `routingRetry: false` to disable retries for transient Durable Object infrastructure errors.
175+
- `routingRetry` - Retry options for transient Durable Object infrastructure errors thrown while routing to the target Durable Object. Enabled by default with 3 attempts and jittered exponential backoff. Only errors with `retryable === true` are retried, and errors with `overloaded === true` are never retried.
175176
- `onBeforeConnect(request, {party: string, name: string}): Request | Response | void` - A function that can modify the request for a websocket connection before it's passed to the server.
176177
- If it returns a `Request`, it will be used instead of the original request. Use this to modify headers or other request properties before passing it to the partyserver instance / durable object.
177178
- If it returns a `Response`, it will be returned instead of the original request. Use this to return a custom response, such as a 404 or 403.

‎packages/partyserver/src/index.ts‎

Lines changed: 169 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,156 @@ const serverMapCache = new WeakMap<
9191
// Maps kebab-case namespace -> original env binding name (e.g. "my-agent" -> "MyAgent")
9292
const bindingNameCache = new WeakMap<object, Record<string, string>>();
9393

94+
const DEFAULT_ROUTING_RETRY_OPTIONS = {
95+
maxAttempts: 3,
96+
baseDelayMs: 100,
97+
maxDelayMs: 800
98+
};
99+
100+
export interface RoutingRetryEvent {
101+
error: unknown;
102+
attempt: number;
103+
maxAttempts: number;
104+
delayMs: number;
105+
name: string;
106+
className?: string;
107+
}
108+
109+
export interface RoutingRetryOptions {
110+
/** Max number of attempts, including the first. Default: 3 */
111+
maxAttempts?: number;
112+
/** Base delay in ms for exponential backoff. Default: 100 */
113+
baseDelayMs?: number;
114+
/** Max delay cap in ms. Default: 800 */
115+
maxDelayMs?: number;
116+
/** Optional callback invoked before each retry delay. */
117+
onRetry?: (event: RoutingRetryEvent) => void | Promise<void>;
118+
}
119+
120+
interface ResolvedRoutingRetryOptions extends Required<
121+
Omit<RoutingRetryOptions, "onRetry">
122+
> {
123+
onRetry?: RoutingRetryOptions["onRetry"];
124+
}
125+
126+
interface RoutingRetryContext {
127+
name: string;
128+
className?: string;
129+
}
130+
131+
function durableObjectGetOptions(
132+
options: { locationHint?: DurableObjectLocationHint } | undefined
133+
) {
134+
return options?.locationHint
135+
? { locationHint: options.locationHint }
136+
: undefined;
137+
}
138+
139+
function validatePositiveInteger(value: number, name: string): void {
140+
if (!Number.isFinite(value) || value < 1) {
141+
throw new Error(`${name} must be >= 1`);
142+
}
143+
if (!Number.isInteger(value)) {
144+
throw new Error(`${name} must be an integer`);
145+
}
146+
}
147+
148+
function validatePositiveNumber(value: number, name: string): void {
149+
if (!Number.isFinite(value) || value <= 0) {
150+
throw new Error(`${name} must be > 0`);
151+
}
152+
}
153+
154+
function resolveRoutingRetryOptions(
155+
options: false | RoutingRetryOptions | undefined
156+
): ResolvedRoutingRetryOptions | null {
157+
if (options === false) return null;
158+
159+
const resolved = {
160+
maxAttempts:
161+
options?.maxAttempts ?? DEFAULT_ROUTING_RETRY_OPTIONS.maxAttempts,
162+
baseDelayMs:
163+
options?.baseDelayMs ?? DEFAULT_ROUTING_RETRY_OPTIONS.baseDelayMs,
164+
maxDelayMs: options?.maxDelayMs ?? DEFAULT_ROUTING_RETRY_OPTIONS.maxDelayMs,
165+
onRetry: options?.onRetry
166+
};
167+
168+
validatePositiveInteger(resolved.maxAttempts, "routingRetry.maxAttempts");
169+
validatePositiveNumber(resolved.baseDelayMs, "routingRetry.baseDelayMs");
170+
validatePositiveNumber(resolved.maxDelayMs, "routingRetry.maxDelayMs");
171+
if (resolved.baseDelayMs > resolved.maxDelayMs) {
172+
throw new Error("routingRetry.baseDelayMs must be <= maxDelayMs");
173+
}
174+
175+
return resolved;
176+
}
177+
178+
function isRetryableDurableObjectError(error: unknown): boolean {
179+
if (typeof error !== "object" || error === null) return false;
180+
const typed = error as { retryable?: boolean; overloaded?: boolean };
181+
return typed.retryable === true && typed.overloaded !== true;
182+
}
183+
184+
function routingRetryDelayMs(
185+
attempt: number,
186+
options: ResolvedRoutingRetryOptions
187+
): number {
188+
const upperBoundMs = Math.min(
189+
options.maxDelayMs,
190+
options.baseDelayMs * 2 ** (attempt - 1)
191+
);
192+
return Math.floor(Math.random() * upperBoundMs);
193+
}
194+
195+
async function retryDurableObjectOperation<T>(
196+
operation: () => Promise<T>,
197+
context: RoutingRetryContext,
198+
retryOptions: false | RoutingRetryOptions | undefined
199+
): Promise<T> {
200+
const resolved = resolveRoutingRetryOptions(retryOptions);
201+
if (!resolved) return await operation();
202+
203+
let attempt = 1;
204+
while (true) {
205+
try {
206+
return await operation();
207+
} catch (error) {
208+
const nextAttempt = attempt + 1;
209+
if (
210+
nextAttempt > resolved.maxAttempts ||
211+
!isRetryableDurableObjectError(error)
212+
) {
213+
throw error;
214+
}
215+
216+
const delayMs = routingRetryDelayMs(attempt, resolved);
217+
try {
218+
await resolved.onRetry?.({
219+
error,
220+
attempt,
221+
maxAttempts: resolved.maxAttempts,
222+
delayMs,
223+
name: context.name,
224+
className: context.className
225+
});
226+
} catch (callbackError) {
227+
console.warn(
228+
"PartyServer routingRetry onRetry callback failed:",
229+
callbackError
230+
);
231+
}
232+
await new Promise((resolve) => setTimeout(resolve, delayMs));
233+
attempt = nextAttempt;
234+
}
235+
}
236+
}
237+
94238
/**
95239
* For a given server namespace, create a server with a name.
96240
*
97-
* Makes a single RPC that awaits the DO's `onStart()` before returning,
98-
* so callers can invoke user-defined RPC methods on the returned stub and
99-
* trust that `onStart()` has completed. (User-defined RPC methods don't
241+
* Makes an RPC that awaits the DO's `onStart()` before returning, so callers
242+
* can invoke user-defined RPC methods on the returned stub and trust that
243+
* `onStart()` has completed. (User-defined RPC methods don't
100244
* otherwise pass through `Server.fetch()`, which is where initialization
101245
* would normally be triggered.)
102246
*
@@ -115,18 +259,23 @@ export async function getServerByName<
115259
jurisdiction?: DurableObjectJurisdiction;
116260
locationHint?: DurableObjectLocationHint;
117261
props?: Props;
262+
routingRetry?: false | RoutingRetryOptions;
118263
}
119264
): Promise<DurableObjectStub<T>> {
120265
if (options?.jurisdiction) {
121266
serverNamespace = serverNamespace.jurisdiction(options.jurisdiction);
122267
}
123268

124269
const id = serverNamespace.idFromName(name);
125-
const stub = serverNamespace.get(id, options);
270+
const getOptions = durableObjectGetOptions(options);
126271

127-
await stub.setName(name, options?.props);
272+
await retryDurableObjectOperation(
273+
() => serverNamespace.get(id, getOptions).setName(name, options?.props),
274+
{ name },
275+
options?.routingRetry
276+
);
128277

129-
return stub;
278+
return serverNamespace.get(id, getOptions);
130279
}
131280

132281
function camelCaseToKebabCase(str: string): string {
@@ -189,6 +338,14 @@ export interface PartyServerOptions<
189338
* Non-WebSocket responses on matched routes will also have the CORS headers appended.
190339
*/
191340
cors?: boolean | HeadersInit;
341+
/**
342+
* Retry transient Durable Object infrastructure errors thrown while routing
343+
* to the target DO. Enabled by default; pass `false` to disable.
344+
*
345+
* Only errors marked `retryable === true` are retried, and overloaded
346+
* errors (`overloaded === true`) are never retried.
347+
*/
348+
routingRetry?: false | RoutingRetryOptions;
192349
onBeforeConnect?: (
193350
req: Request,
194351
lobby: Lobby<Env>
@@ -320,7 +477,7 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
320477
}
321478

322479
const id = doNamespace.idFromName(name);
323-
const stub = doNamespace.get(id, options);
480+
const getOptions = durableObjectGetOptions(options);
324481

325482
req = new Request(req);
326483
req.headers.set("x-partykit-namespace", namespace);
@@ -377,7 +534,11 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp
377534
// `ctx.id.name` inside the DO, so there's no need for a prior
378535
// `setName()` round-trip. Props (if any) travel in the request header
379536
// and are picked up in `Server.fetch()`.
380-
const response = await stub.fetch(req);
537+
const response = await retryDurableObjectOperation(
538+
() => doNamespace.get(id, getOptions).fetch(req.clone()),
539+
{ name, className },
540+
options?.routingRetry
541+
);
381542
return isWebSocket ? response : withCorsHeaders(response);
382543
} else {
383544
return null;

0 commit comments

Comments
 (0)