fix: make the proxy deadline per-request, not unconditional (#2319 round 13)
The server-side timer added in round 9 applied to every /api/fetch request. But that route carries MCP traffic as well as OAuth work, so a Streamable HTTP tool call that legitimately withholds its response headers past the budget was aborted there — reintroducing on the backend exactly the regression exemptMcpEndpoint prevents on the client, and reporting it as an OAuth timeout besides. The caller now decides. `withOAuthRequestTimeout` stamps its budget on the RequestInit it hands down, `createRemoteFetch` reads it back and puts it in the /api/fetch envelope, and the route applies a timer only when one is present — so an exempt MCP request, which never gets an init of its own, is bounded by nothing. The carrier is a WeakMap keyed on the init rather than a header: serializeRequest copies request headers verbatim into the payload and the route re-sends them to the authorization server, so a marker header would leak to a third party. It needs no cast onto RequestInit and is collected with the object. The `fetchTimeoutMs` option from round 9 is gone — the deadline now lives in exactly one place, the client wrapper, which is also what makes the 504 marker honest: only a request the OAuth wrapper bounded can produce one. Ten tests: the stamping and its rounding, nothing stamped on an exempt call or an init that never went through the wrapper, the envelope carrying the budget (and not a header), its omission on both exempt and direct calls, and at the route the 504 marker, no deadline without one, and a non-numeric deadline ignored. The no-deadline case is verified by mutation: an unconditional timer makes it fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
234d0a9ac4
commit
de6960ec5a
3 changed files with 259 additions and 50 deletions
|
|
@ -25,6 +25,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
|||
import {
|
||||
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
||||
OAuthRequestTimeoutError,
|
||||
deadlineForRequestInit,
|
||||
exemptMcpEndpoint,
|
||||
withOAuthRequestTimeout,
|
||||
} from "@inspector/core/auth/requestTimeout.js";
|
||||
|
|
@ -414,6 +415,46 @@ describe("withOAuthRequestTimeout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("the deadline stamped on the init (#2319 proxy hop)", () => {
|
||||
// How the budget reaches `/api/fetch` without travelling upstream: a header
|
||||
// would be copied verbatim into the payload and re-sent to the
|
||||
// authorization server, so the carrier is a WeakMap keyed on the init.
|
||||
it("stamps the budget on the init it hands down", async () => {
|
||||
const inner = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
|
||||
const wrapped = withOAuthRequestTimeout(inner, 1234);
|
||||
|
||||
await wrapped(URL_UNDER_TEST);
|
||||
|
||||
expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1234);
|
||||
});
|
||||
|
||||
it("stamps the rounded budget, matching what is reported", async () => {
|
||||
const inner = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
|
||||
const wrapped = withOAuthRequestTimeout(inner, 1000.4);
|
||||
|
||||
await wrapped(URL_UNDER_TEST);
|
||||
|
||||
expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1000);
|
||||
});
|
||||
|
||||
it("stamps nothing on an exempt request", async () => {
|
||||
// The route must apply no deadline to MCP traffic, and it decides that
|
||||
// from the absence of a budget in the envelope.
|
||||
const inner = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
|
||||
const wrapped = withOAuthRequestTimeout(inner, 1234, () => true);
|
||||
|
||||
await wrapped(URL_UNDER_TEST, { method: "POST" });
|
||||
|
||||
expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads nothing off an init that never went through the wrapper", () => {
|
||||
expect(deadlineForRequestInit(undefined)).toBeUndefined();
|
||||
expect(deadlineForRequestInit({})).toBeUndefined();
|
||||
expect(deadlineForRequestInit({ method: "GET" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => {
|
||||
const SERVER = "https://srv.example.com/mcp";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.
|
|||
import {
|
||||
OAUTH_TIMEOUT_WIRE_CODE,
|
||||
OAuthRequestTimeoutError,
|
||||
withOAuthRequestTimeout,
|
||||
} from "@inspector/core/auth/requestTimeout.js";
|
||||
|
||||
function ok(body: object): Response {
|
||||
|
|
@ -334,4 +335,61 @@ describe("createRemoteFetch", () => {
|
|||
expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carrying the caller's deadline to the route (#2319)", () => {
|
||||
function envelopeOf(fetchFn: ReturnType<typeof vi.fn<typeof fetch>>) {
|
||||
const init = fetchFn.mock.calls[0][1] as RequestInit;
|
||||
return JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("puts a bounded call's budget in the envelope, not in the headers", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(ok(standardRemoteBody));
|
||||
const remoteFetch = createRemoteFetch({
|
||||
baseUrl: "http://remote.example",
|
||||
fetchFn: (input, init) => fetchFn(input, init),
|
||||
});
|
||||
// Composed the way `InspectorClient` composes it: the wrapper outside,
|
||||
// the remote fetch beneath.
|
||||
const bounded = withOAuthRequestTimeout(remoteFetch, 7000);
|
||||
|
||||
await bounded("http://upstream.example/token");
|
||||
|
||||
const envelope = envelopeOf(fetchFn);
|
||||
expect(envelope.timeoutMs).toBe(7000);
|
||||
// Not a header: `headers` is re-sent verbatim to the upstream server, so
|
||||
// a marker there would leak the Inspector's internals to a third party.
|
||||
expect(JSON.stringify(envelope.headers)).not.toContain("7000");
|
||||
});
|
||||
|
||||
it("omits the budget for an exempt call, so the route bounds nothing", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(ok(standardRemoteBody));
|
||||
const remoteFetch = createRemoteFetch({
|
||||
baseUrl: "http://remote.example",
|
||||
fetchFn: (input, init) => fetchFn(input, init),
|
||||
});
|
||||
const bounded = withOAuthRequestTimeout(remoteFetch, 7000, () => true);
|
||||
|
||||
await bounded("http://upstream.example/mcp");
|
||||
|
||||
expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs");
|
||||
});
|
||||
|
||||
it("omits the budget for a direct call that no wrapper bounded", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(ok(standardRemoteBody));
|
||||
const remoteFetch = createRemoteFetch({
|
||||
baseUrl: "http://remote.example",
|
||||
fetchFn: (input, init) => fetchFn(input, init),
|
||||
});
|
||||
|
||||
await remoteFetch("http://upstream.example/mcp");
|
||||
|
||||
expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ interface StartOpts {
|
|||
secretStore?: SecretStore;
|
||||
logger?: pinoType.Logger;
|
||||
seedConfig?: string;
|
||||
fetchTimeoutMs?: number;
|
||||
}
|
||||
|
||||
async function start(opts: StartOpts = {}): Promise<Harness> {
|
||||
|
|
@ -66,9 +65,6 @@ async function start(opts: StartOpts = {}): Promise<Harness> {
|
|||
initialConfig: { defaultEnvironment: {} },
|
||||
secretStore: opts.secretStore ?? new InMemorySecretStore(),
|
||||
logger: opts.logger,
|
||||
...(opts.fetchTimeoutMs !== undefined && {
|
||||
fetchTimeoutMs: opts.fetchTimeoutMs,
|
||||
}),
|
||||
});
|
||||
const { baseUrl, server } = await new Promise<{
|
||||
baseUrl: string;
|
||||
|
|
@ -475,19 +471,16 @@ describe("server.ts supplemental coverage", () => {
|
|||
await openStreamClosed;
|
||||
});
|
||||
|
||||
it("answers 504 with a typed marker when its own deadline fires (#2319)", async () => {
|
||||
// The backstop, on its own short budget so this costs milliseconds rather
|
||||
// than the production thirty seconds. It matters because this is the only
|
||||
// deadline on the transport-internal discovery path, which has no
|
||||
// client-side wrapper — so its error has to survive the hop as something
|
||||
// an `instanceof OAuthRequestTimeoutError` check can still recognize.
|
||||
await stop(h);
|
||||
h = await start({ fetchTimeoutMs: 120 });
|
||||
|
||||
it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => {
|
||||
// The caller's budget travels in the envelope, so this costs milliseconds
|
||||
// rather than the production thirty seconds. The marker matters because
|
||||
// a backend-enforced deadline is the only one on the transport-internal
|
||||
// discovery path, and its error has to survive the hop as something an
|
||||
// `instanceof OAuthRequestTimeoutError` check can still recognize.
|
||||
const res = await fetch(`${h.baseUrl}/api/fetch`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: `${targetUrl}/stall` }),
|
||||
body: JSON.stringify({ url: `${targetUrl}/stall`, timeoutMs: 120 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(504);
|
||||
|
|
@ -505,6 +498,65 @@ describe("server.ts supplemental coverage", () => {
|
|||
await stallClosed;
|
||||
});
|
||||
|
||||
it("applies no deadline to a request that carries none (#2319)", async () => {
|
||||
// ⚠️ The regression this guards: an unconditional timer here would abort
|
||||
// a Streamable HTTP tool call that legitimately withholds its response
|
||||
// headers — reintroducing on the backend exactly what `exemptMcpEndpoint`
|
||||
// prevents on the client, and reporting it as an OAuth timeout besides.
|
||||
const caller = new AbortController();
|
||||
const pending = fetch(`${h.baseUrl}/api/fetch`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: `${targetUrl}/stall` }),
|
||||
signal: caller.signal,
|
||||
});
|
||||
const settled = pending.then(
|
||||
(r) => `responded ${r.status}`,
|
||||
() => "aborted by us",
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(upstreamHits).toContain("/stall");
|
||||
});
|
||||
// Well past any budget the route might have applied on its own. Nothing
|
||||
// should have answered: an unbounded request is still in flight.
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(
|
||||
await Promise.race([settled, Promise.resolve("still pending")]),
|
||||
).toBe("still pending");
|
||||
|
||||
caller.abort();
|
||||
expect(await settled).toBe("aborted by us");
|
||||
});
|
||||
|
||||
it("ignores a non-numeric deadline rather than trusting it (#2319)", async () => {
|
||||
const caller = new AbortController();
|
||||
const pending = fetch(`${h.baseUrl}/api/fetch`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: `${targetUrl}/stall`,
|
||||
timeoutMs: "soon",
|
||||
}),
|
||||
signal: caller.signal,
|
||||
});
|
||||
const settled = pending.then(
|
||||
(r) => `responded ${r.status}`,
|
||||
() => "aborted by us",
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(upstreamHits).toContain("/stall");
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(
|
||||
await Promise.race([settled, Promise.resolve("still pending")]),
|
||||
).toBe("still pending");
|
||||
|
||||
caller.abort();
|
||||
expect(await settled).toBe("aborted by us");
|
||||
});
|
||||
|
||||
it("routes the outbound request through HTTP_PROXY (#2067)", async () => {
|
||||
// /api/fetch is the browser's ONLY way out to the network — the web
|
||||
// client's `environment.fetch` is `createRemoteFetch()`, which forwards
|
||||
|
|
|
|||
Loading…
Reference in a new issue