Skip to content

Commit 93eedce

Browse files
authored
Retry transient Durable Object relocation on MCP session init (#1034)
Cloudflare may relocate a live Durable Object between machines; an in-flight `init` then throws "cannot access storage because object has moved to a different machine". The worker collapsed that rejection into a defect and surfaced it to the client as a JSON-RPC -32603, which a reconnect cleared on the next attempt. Retry that specific transient class on the create/init path with a bounded, jittered backoff, annotating the recovered-retry count on the span. If it never clears (or any non-relocation rejection occurs) the original value is re-raised unchanged, preserving the prior Effect.promise -> -32603 behavior. The forward path (handleRequest) is intentionally left alone: its Request body is a one-shot stream that cannot be safely replayed. Covered by an injection test at the McpSessionDOStub seam: recovery after a relocation, the bounded-attempt give-up, and no retry of a non-relocation failure.
1 parent e4db068 commit 93eedce

2 files changed

Lines changed: 231 additions & 6 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// ---------------------------------------------------------------------------
2+
// Regression coverage for the transient DO-relocation retry in the worker-side
3+
// session-store dispatcher.
4+
//
5+
// Cloudflare may relocate a live Durable Object between machines; an in-flight
6+
// `init` then throws "cannot access storage because object has moved to a
7+
// different machine". Before the retry, that rejection became an unrecoverable
8+
// defect the envelope rendered as a -32603 — exactly the prod incident on
9+
// 2026-06-15 (one `mcp.do.init` failure a reconnect cleared). These tests inject
10+
// that rejection at the `McpSessionDOStub` seam and pin the recovery behavior.
11+
// ---------------------------------------------------------------------------
12+
13+
import { describe, expect, it } from "@effect/vitest";
14+
import { Cause, Effect, Exit } from "effect";
15+
16+
import { McpSessionStore, type McpDispatchResult, type Principal } from "@executor-js/host-mcp";
17+
18+
import {
19+
DO_RELOCATION_MAX_RETRIES,
20+
makeDurableObjectMcpSessionStore,
21+
type McpSessionDOStub,
22+
} from "./session-store";
23+
24+
const RELOCATION_ERROR = "cannot access storage because object has moved to a different machine";
25+
26+
const TEST_PRINCIPAL: Principal = {
27+
accountId: "user_test",
28+
organizationId: "org_test",
29+
organizationName: "Test Org",
30+
email: "test@example.com",
31+
name: "Test",
32+
avatarUrl: null,
33+
roles: ["user"],
34+
};
35+
36+
/** A POST with no session id — routes dispatch through the create/init path. */
37+
const initializeRequest = (): Request =>
38+
new Request("https://mcp.test/mcp", {
39+
method: "POST",
40+
headers: { authorization: "Bearer x", "content-type": "application/json" },
41+
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize" }),
42+
});
43+
44+
/** The JSON-RPC body the faked DO returns once `init` succeeds. */
45+
const okResponse = (): Response =>
46+
new Response(JSON.stringify({ jsonrpc: "2.0", id: 0, result: {} }), {
47+
status: 200,
48+
headers: { "content-type": "application/json" },
49+
});
50+
51+
/** Drive `dispatch` for a create over a store built from one fake stub. */
52+
const dispatchCreate = (stub: McpSessionDOStub): Effect.Effect<McpDispatchResult> =>
53+
Effect.gen(function* () {
54+
const store = yield* McpSessionStore;
55+
return yield* store.dispatch({
56+
request: initializeRequest(),
57+
principal: TEST_PRINCIPAL,
58+
sessionId: null,
59+
method: "POST",
60+
});
61+
}).pipe(
62+
Effect.provide(makeDurableObjectMcpSessionStore({ newStub: () => stub, getStub: () => stub })),
63+
);
64+
65+
/** Rendered cause of a failed dispatch (empty for a success) — keeps the
66+
* message assertion unconditional, off the `Exit.isFailure` branch. */
67+
const failureText = (exit: Exit.Exit<McpDispatchResult>): string =>
68+
Exit.isFailure(exit) ? Cause.pretty(exit.cause) : "";
69+
70+
describe("makeDurableObjectMcpSessionStore — DO-relocation retry", () => {
71+
it.live("retries mcp.do.init past a relocation, then returns the DO response", () =>
72+
Effect.gen(function* () {
73+
let initCalls = 0;
74+
let handleCalls = 0;
75+
const stub: McpSessionDOStub = {
76+
init: () => {
77+
initCalls += 1;
78+
if (initCalls === 1) {
79+
// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fake DO stub rejects to simulate a Cloudflare relocation throw
80+
return Promise.reject(new Error(RELOCATION_ERROR));
81+
}
82+
return Promise.resolve();
83+
},
84+
handleRequest: () => {
85+
handleCalls += 1;
86+
return Promise.resolve(okResponse());
87+
},
88+
clearSession: () => Promise.resolve(),
89+
};
90+
91+
const result = yield* dispatchCreate(stub);
92+
93+
expect(initCalls, "init retried once after the relocation").toBe(2);
94+
expect(handleCalls, "handleRequest runs once, after init recovers").toBe(1);
95+
expect(result).toBeInstanceOf(Response);
96+
expect((result as Response).status).toBe(200);
97+
}),
98+
);
99+
100+
it.live("gives up after the retry budget when relocation never clears", () =>
101+
Effect.gen(function* () {
102+
let initCalls = 0;
103+
let handleCalls = 0;
104+
const stub: McpSessionDOStub = {
105+
init: () => {
106+
initCalls += 1;
107+
// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fake DO stub rejects to simulate a Cloudflare relocation throw
108+
return Promise.reject(new Error(RELOCATION_ERROR));
109+
},
110+
handleRequest: () => {
111+
handleCalls += 1;
112+
return Promise.resolve(okResponse());
113+
},
114+
clearSession: () => Promise.resolve(),
115+
};
116+
117+
const exit = yield* Effect.exit(dispatchCreate(stub));
118+
119+
expect(Exit.isFailure(exit), "exhausted retries surface as a defect").toBe(true);
120+
expect(initCalls, "one initial attempt plus the full retry budget").toBe(
121+
1 + DO_RELOCATION_MAX_RETRIES,
122+
);
123+
expect(handleCalls, "handleRequest is never reached when init never succeeds").toBe(0);
124+
expect(failureText(exit), "the relocation cause is preserved").toContain(RELOCATION_ERROR);
125+
}),
126+
);
127+
128+
it.live("does not retry a non-relocation failure — surfaces it immediately", () =>
129+
Effect.gen(function* () {
130+
let initCalls = 0;
131+
const stub: McpSessionDOStub = {
132+
init: () => {
133+
initCalls += 1;
134+
// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fake DO stub rejects to simulate a non-transient init failure
135+
return Promise.reject(new Error("schema spread bug — not transient"));
136+
},
137+
handleRequest: () => Promise.resolve(okResponse()),
138+
clearSession: () => Promise.resolve(),
139+
};
140+
141+
const exit = yield* Effect.exit(dispatchCreate(stub));
142+
143+
expect(Exit.isFailure(exit), "a non-transient rejection still dies").toBe(true);
144+
expect(initCalls, "non-relocation errors are not retried").toBe(1);
145+
expect(failureText(exit), "the original failure is preserved").toContain("schema spread bug");
146+
}),
147+
);
148+
});

packages/hosts/cloudflare/src/mcp/session-store.ts

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
// and DELETE (204) before dispatch, so dispatch only sees create or forward.
2020
// ---------------------------------------------------------------------------
2121

22-
import { Effect, Layer } from "effect";
22+
import { Data, Effect, Layer, Schedule } from "effect";
2323

2424
import {
2525
McpSessionStore,
@@ -49,6 +49,87 @@ export interface DurableObjectStoreConfig {
4949
readonly onInternalError?: OnInternalJsonRpcError;
5050
}
5151

52+
// ---------------------------------------------------------------------------
53+
// Transient DO-relocation retry.
54+
//
55+
// Cloudflare may relocate a live Durable Object between machines; an in-flight
56+
// stub call against the now-stale instance throws "cannot access storage
57+
// because object has moved to a different machine". The runtime throws this
58+
// BEFORE the object touched storage, so no work and no writes happened on that
59+
// instance — a re-issued stub call resolves the DO's new location and succeeds.
60+
// (Observed in prod 2026-06-15: a single `mcp.do.init` failure surfaced to the
61+
// client as a -32603 "Internal server error" that a reconnect cleared.)
62+
//
63+
// We retry ONLY this relocation class, and ONLY `init` — init takes a
64+
// structured `meta` object with no request body, so replaying it is safe. The
65+
// forward path (`handleRequest`) is deliberately excluded: its `Request` body is
66+
// a one-shot stream that cannot be re-sent on a retry without buffering.
67+
// ---------------------------------------------------------------------------
68+
69+
const RELOCATED_DO_ERROR_FRAGMENT = "moved to a different machine";
70+
71+
/** A rejected DO stub call, normalized at the boundary: the original thrown
72+
* value in `cause` (so the final `Effect.die` re-raises it verbatim) plus a
73+
* pre-computed `relocated` flag so the retry gate never re-inspects `unknown`. */
74+
class DoStubError extends Data.TaggedError("DoStubError")<{
75+
readonly cause: unknown;
76+
readonly relocated: boolean;
77+
}> {}
78+
79+
/** Normalize a rejected DO stub call, flagging the Cloudflare relocation class —
80+
* the only safe-to-retry error, and detectable only via the thrown message. */
81+
const toDoStubError = (cause: unknown): DoStubError =>
82+
new DoStubError({
83+
cause,
84+
// oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: Cloudflare signals DO relocation only via the thrown Error's message
85+
relocated: cause instanceof Error && cause.message.includes(RELOCATED_DO_ERROR_FRAGMENT),
86+
});
87+
88+
/** Up to 4 attempts total (1 + 3 retries) at ~25/50/100ms jittered backoff. */
89+
export const DO_RELOCATION_MAX_RETRIES = 3;
90+
const DO_RELOCATION_RETRY_SCHEDULE = Schedule.jittered(Schedule.exponential("25 millis"));
91+
92+
/**
93+
* Run a replayable DO stub call under one `withSpan`, retrying the DO-relocation
94+
* error a bounded number of times. A call that still fails after retries (or any
95+
* non-relocation rejection) becomes an unrecoverable defect — exactly the prior
96+
* `Effect.promise` semantics, which the envelope's top-level `catchCause` renders
97+
* as a -32603 and reports to the error reporter (cloud: Sentry).
98+
*/
99+
const withDoRelocationRetry = <A>(
100+
spanName: string,
101+
attributes: Record<string, string | number | boolean>,
102+
call: () => Promise<A>,
103+
): Effect.Effect<A> =>
104+
Effect.suspend(() => {
105+
let attempts = 0;
106+
return Effect.tryPromise({
107+
try: () => {
108+
attempts += 1;
109+
return call();
110+
},
111+
catch: toDoStubError,
112+
}).pipe(
113+
// Gate retries on BOTH the relocation class and a hard attempt budget. The
114+
// `attempts` counter (bumped in `try`) bounds this directly rather than via
115+
// `retry`'s `times`, whose interaction with `while` is unreliable here.
116+
Effect.retry({
117+
schedule: DO_RELOCATION_RETRY_SCHEDULE,
118+
while: (error) => error.relocated && attempts <= DO_RELOCATION_MAX_RETRIES,
119+
}),
120+
Effect.tap(() =>
121+
attempts > 1
122+
? Effect.annotateCurrentSpan({ "mcp.do.relocation_retries": attempts - 1 })
123+
: Effect.void,
124+
),
125+
// A call that still fails (or any non-relocation rejection) re-raises the
126+
// original thrown value as an unrecoverable defect — the prior
127+
// `Effect.promise` semantics the envelope renders as a -32603.
128+
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: dispatch's contract is E=never; an exhausted DO RPC re-raises as the defect the envelope renders as -32603
129+
Effect.catch((error) => Effect.die(error.cause)),
130+
);
131+
}).pipe(Effect.withSpan(spanName, { attributes }));
132+
52133
/**
53134
* Forward a request to an existing session DO. `peek` tees the body for
54135
* telemetry on POST/DELETE; GET (SSE) streams through untouched. Returns the DO
@@ -91,7 +172,7 @@ const createSession = (
91172
Effect.gen(function* () {
92173
const stub = config.newStub();
93174
const propagation = yield* currentPropagationHeaders(request);
94-
yield* Effect.promise(() =>
175+
yield* withDoRelocationRetry("mcp.do.init", { "mcp.request.session_id_present": false }, () =>
95176
stub.init(
96177
{
97178
organizationId: token.organizationId,
@@ -104,10 +185,6 @@ const createSession = (
104185
},
105186
propagation,
106187
),
107-
).pipe(
108-
Effect.withSpan("mcp.do.init", {
109-
attributes: { "mcp.request.session_id_present": false },
110-
}),
111188
);
112189
const propagated = withPropagationHeaders(
113190
withVerifiedIdentityHeaders(request, token),

0 commit comments

Comments
 (0)