Skip to content

Commit 0453a31

Browse files
ra-co88pt-actRhysSullivan
authored
fix(e2e): honor the 503 restart envelope in the cap-eviction helper (#1895)
* trim to the openSession restart-retry backstop Per review: the concurrency half is superseded by #1907 (already on main, stricter). Rebased onto current main and kept only the retry loop on the documented 503 restart envelope. * Match the exact MCP restart envelope before retrying * Test queue timeout with a controlled clock --------- Co-authored-by: pt-act <pt-act@users.noreply.github.com> Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 1f67d83 commit 0453a31

1 file changed

Lines changed: 97 additions & 14 deletions

File tree

‎e2e/cloud/mcp-session-cap-eviction.test.ts‎

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
// the whole boot (see that file for the value and its headroom story), so
2020
// this test can cross it with a bounded number of real sessions instead of
2121
// registering the production default of 32.
22-
import { expect } from "@effect/vitest";
23-
import { Effect, Schedule } from "effect";
22+
import { expect, it } from "@effect/vitest";
23+
import { Effect, Option, Schedule, Schema } from "effect";
2424

2525
import { scenario } from "../src/scenario";
2626
import { Mcp, Target, Telemetry } from "../src/services";
@@ -52,6 +52,68 @@ const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: str
5252
body: JSON.stringify(body),
5353
});
5454

55+
const decodeRestartEnvelope = Schema.decodeUnknownOption(
56+
Schema.fromJsonString(
57+
Schema.Struct({
58+
jsonrpc: Schema.Literal("2.0"),
59+
id: Schema.Null,
60+
error: Schema.Struct({
61+
code: Schema.Literal(-32001),
62+
message: Schema.Literal("MCP session is restarting, please retry"),
63+
}),
64+
}),
65+
),
66+
);
67+
68+
const isRestartResponse = (status: number, body: string): boolean =>
69+
status === 503 && Option.isSome(decodeRestartEnvelope(body));
70+
71+
it.each([
72+
[
73+
503,
74+
{
75+
jsonrpc: "2.0",
76+
id: null,
77+
error: { code: -32001, message: "MCP session is restarting, please retry" },
78+
},
79+
true,
80+
],
81+
[
82+
404,
83+
{
84+
jsonrpc: "2.0",
85+
id: null,
86+
error: { code: -32001, message: "MCP session is restarting, please retry" },
87+
},
88+
false,
89+
],
90+
[
91+
503,
92+
{
93+
jsonrpc: "2.0",
94+
id: null,
95+
error: { code: -32603, message: "MCP session is restarting, please retry" },
96+
},
97+
false,
98+
],
99+
[
100+
503,
101+
{
102+
jsonrpc: "2.0",
103+
id: null,
104+
error: { code: -32001, message: "MCP session is restarting unexpectedly" },
105+
},
106+
false,
107+
],
108+
[503, { error: "MCP session is restarting, please retry" }, false],
109+
] as const)("only retries the documented restart envelope (%s, %j)", (status, body, retry) => {
110+
expect(isRestartResponse(status, JSON.stringify(body))).toBe(retry);
111+
});
112+
113+
it("does not retry malformed restart responses", () => {
114+
expect(isRestartResponse(503, "MCP session is restarting, please retry")).toBe(false);
115+
});
116+
55117
/**
56118
* Opens one fresh MCP session under an already-minted bearer. `initialize`
57119
* without an existing `mcp-session-id` always mints a new session, the same
@@ -73,21 +135,42 @@ const openSession = async (
73135
label: string,
74136
recordSession: (sessionId: string) => void,
75137
): Promise<string> => {
76-
const initialized = await postJson(mcpUrl, bearer, {
77-
jsonrpc: "2.0" as const,
78-
id: "initialize",
79-
method: "initialize",
80-
params: {
81-
protocolVersion: PROTOCOL_VERSION,
82-
capabilities: {},
83-
clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" },
84-
},
85-
});
86-
const sessionId = initialized.headers.get("mcp-session-id");
87-
if (!sessionId) {
138+
// The platform can reset a session Durable Object while its initialize
139+
// is in flight, and the server answers that with the documented restart
140+
// envelope (503, -32001, "MCP session is restarting, please retry") — the
141+
// same contract a streamable-http client follows: same request, after the
142+
// advertised delay. Treat it as transient here instead of failing the
143+
// scenario on a retryable platform blip.
144+
const RESTART_ATTEMPTS = 8;
145+
const RESTART_DELAY_MS = 2_000; // The host advertises Retry-After: 2.
146+
let minted: { readonly response: Response; readonly sessionId: string } | undefined;
147+
for (let attempt = 0; attempt < RESTART_ATTEMPTS; attempt += 1) {
148+
const response = await postJson(mcpUrl, bearer, {
149+
jsonrpc: "2.0" as const,
150+
id: "initialize",
151+
method: "initialize",
152+
params: {
153+
protocolVersion: PROTOCOL_VERSION,
154+
capabilities: {},
155+
clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" },
156+
},
157+
});
158+
const candidate = response.headers.get("mcp-session-id");
159+
if (candidate !== null && candidate.length > 0) {
160+
minted = { response, sessionId: candidate };
161+
break;
162+
}
163+
const body = await response.text();
164+
const isRestart = isRestartResponse(response.status, body);
165+
if (!isRestart) break;
166+
if (attempt === RESTART_ATTEMPTS - 1) break;
167+
await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS));
168+
}
169+
if (!minted) {
88170
// oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition.
89171
throw new Error(`openSession (${label}): no mcp-session-id header`);
90172
}
173+
const { response: initialized, sessionId } = minted;
91174
// Recorded the moment the id exists — BEFORE the body read and status
92175
// assertion below, either of which can throw with the session already live
93176
// on the server. The cleanup finalizer needs the id on every one of those

0 commit comments

Comments
 (0)