You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Recognize HTTP 429 and surface the server's Retry-After - #2737
A request that a server throttles now rejects with a message that says it is rate limiting, which request was throttled, and when the server asked to retry. Before, it showed only the SDK's generic error:
Rate limited by the server (HTTP 429) on tools/list. The server asked to retry after 30s. Error POSTing to endpoint: slow down
With no header, the message says The server sent no Retry-After header. A malformed header is quoted as-is rather than guessed at. The structured form (method, retryAfter, retryAfterMs) goes on error.data as AnnotatedRateLimitData, for any consumer that wants a countdown or JSON output.
How
core/mcp/rateLimit.ts (new, pure): parseRetryAfter handles delta-seconds and IMF-fixdate. Date.parse alone reads -5 as a year, so it is not trusted on its own. The module also has isRateLimitError and annotateRateLimit. The rebuilt error keeps the original's class (SdkHttpError), code and status, so any status or code check downstream sees the same thing it did before; the original error is kept as cause.
InspectorClient: one annotateRequestError applies both the Report outstanding-request and notification-stream state on a request timeout, not a bare "Request timed out" #2318 timeout annotation and this one. It serves the client.request funnel the timeout diagnostics already decorated (renamed annotateSdkRequestErrors) and the ext-tasks raw-wire channel that bypasses it (modern-era tool calls and tasks/* against a server advertising the tasks extension). So a request in any of the three clients gets it, with no per-call-site catch.
Where Retry-After comes from: the SDK's SdkHttpError keeps the status but drops every response header, so the header is read from the fetch tracker. A RateLimitLedger files each 429's header under the JSON-RPC id(s) in its tracked request body. trackSendFailure is the one point that knows which message a failed send was carrying, and it stamps that id's header onto the error. So two requests throttled at once each report their own server's Retry-After, whatever order the responses land in.
Web backend: in web mode the upstream fetch runs in the backend. The backend's RemoteSession keeps its own ledger, and the send route takes the header for the id it was sending and relays retryAfter on the typed HTTP error (extending Web client: protocol era "auto" does not fall back to legacy when a 2025-only server rejects the probe with HTTP 400 #2660's RemoteHttpErrorData). That way the browser doesn't depend on a fetch event arriving before the send's HTTP response, which travel over two different connections.
Screenshots
Web client, Tools screen, connected to a fixture server that answers tools/list with 429 Too Many Requests and Retry-After: 30. Same fixture and build steps on both sides; only the source differs (origin/v2/main vs this branch).
Before: the generic SDK error, with nothing to say this is throttling:
After: named as rate limiting, with the throttled request and the server's retry timing:
The mid-word wrap (HTTP 42/9) is the error box's existing monospace wrapping, unchanged by this PR.
Scope
The issue asks for the 429 + Retry-After signal. Two things are deliberately left out:
503 + Retry-After is not treated as rate limiting. 503 means "unavailable", which is exactly the case the issue wants told apart from throttling.
Heuristics like "a batch of failures" or "a latency cliff" are not added. They would be a diagnosis the Inspector can't verify from its own side. That's the same stance connectionDiagnostics.ts takes for timeouts.
Tests
src/test/core/mcp/rateLimit.test.ts: every branch of the pure module.
src/test/integration/mcp/inspectorClient-rate-limit.test.ts: a hand-rolled throttling server driven through the real Node transport (with a header, without one, a 503 left untouched) and through the real web backend + remote transport. Two more cases overlap two throttled requests (the first one's body is held back until the second has rejected) on both transports. They fail under the original "latest 429" behavior and pass with the id correlation. Two modern-era cases throttle tools/call on a tasks-extension server, which takes the raw wire; they fail with the raw-wire hook reverted to timeout-only.
A throttled request surfaced as the SDK's bare "Error POSTing to endpoint",
indistinguishable from any other non-OK status. Annotate a 429 on the same
request funnel the #2318 timeout diagnostics use, so all three clients say
it is rate limiting, which request was throttled, and when the server asked
to retry.
The SDK's SdkHttpError drops response headers, so Retry-After comes from the
fetch tracker. On the web path the upstream fetch runs in the backend, which
relays the header on the typed send error rather than leaving the browser to
race a fetch event against the send response.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
"The latest 429 since the request started" let a concurrent request's
response overwrite another's: request A, whose 429 body was still in flight,
reported request B's Retry-After. File each 429's header under the request
id(s) in its tracked request body, and stamp it onto the error at the point
that knows which message failed — trackSendFailure directly, the send route
on the web backend.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
Copilot round 1, mirrored here because inline replies hide once the threads are outdated:
Correlate Retry-After with each upstream request (medium): fixed in ea35ae3. Retry-After is now matched by JSON-RPC id through a RateLimitLedger, on both the direct path (trackSendFailure) and the web backend (the send route). Overlap regressions were added for both transports and mutation-checked against the old behavior.
Also added before/after web screenshots to the PR body. npm run local:gate passes on ea35ae3. Requesting round 2.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Raw-wire operations bypass rate-limit annotation, and impossible calendar dates can produce incorrect retry timing.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Annotate 429 errors on the raw-wire dispatch path
core/mcp/inspectorClient.ts:1124
This decorator only covers Client.request. Modern tool calls and task operations use dispatchTaskRequest → RawWireChannel.dispatch, whose send-rejection handler still calls the timeout-only host callback (inspectorClient.ts:539–540). A 429 on that path therefore retains the generic HTTP error and lacks method and retryAfterMs, even after its header is attached. Apply rate-limit annotation to the raw-wire rejection path too, preserving timeout handling, and add a modern-era 429 regression; the new integration cases all force legacy negotiation.
Reject impossible dates instead of normalizing them
core/mcp/rateLimit.ts:138
Date.parse normalizes some impossible calendar dates instead of returning NaN. For example, Tue, 31 Nov 2026 12:00:00 GMT becomes 1 December, so this reports an invented retry delay rather than showing the malformed header verbatim. Reject dates that do not round-trip to the original IMF-fixdate, and add an impossible-day regression test.
A modern-era tool call against a server advertising the tasks extension
goes through ext-tasks' raw-wire channel, whose host hook applied only the
timeout annotation, so its 429 kept the generic message. Both paths now
share one annotateRequestError, and the hook is renamed annotateError.
Date.parse rolls an impossible IMF-fixdate (31 Nov) over to a real one, so
a Retry-After is accepted only if it prints back exactly as sent.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
Copilot round 2. Both findings were in the "previously missed" block, which has no inline thread, so they are answered here:
Annotate 429 errors on the raw-wire dispatch path (medium): fixed in 4c02f4b. InspectorClient.annotateRequestError now applies both the timeout and the rate-limit annotation, and serves both the SDK funnel and RawWireChannel (its host hook is renamed annotateTimeout → annotateError). Regression: modern-era tools/call against a server advertising io.modelcontextprotocol/tasks, which is what routes ext-tasks onto rawDispatch. Without that extension a modern tools/call still goes through client.request, and my first version of this test passed with the fix reverted for exactly that reason. Covered on both the direct and web-backend transports; both cases fail with the raw-wire hook reverted to timeout-only.
Reject impossible dates instead of normalizing them (medium): fixed in 4c02f4b. The parsed date must print back via toUTCString() as exactly the header sent, so Tue, 31 Nov 2026 … (rolled to 1 Dec) and a mismatched weekday are both reported verbatim as unparseable. Unit cases added for both.
npm run local:gate passes on 4c02f4b. Requesting round 3.
Copilot review loop closed: round 3 was clean ("Approval recommended", no inline comments, no suppressed findings). Three rounds total: round 1's correlation finding and round 2's two findings were all in-scope defects, all fixed and covered by regression tests that fail with the fix reverted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2442
What
A request that a server throttles now rejects with a message that says it is rate limiting, which request was throttled, and when the server asked to retry. Before, it showed only the SDK's generic error:
With no header, the message says
The server sent no Retry-After header.A malformed header is quoted as-is rather than guessed at. The structured form (method,retryAfter,retryAfterMs) goes onerror.dataasAnnotatedRateLimitData, for any consumer that wants a countdown or JSON output.How
core/mcp/rateLimit.ts(new, pure):parseRetryAfterhandles delta-seconds and IMF-fixdate.Date.parsealone reads-5as a year, so it is not trusted on its own. The module also hasisRateLimitErrorandannotateRateLimit. The rebuilt error keeps the original's class (SdkHttpError), code and status, so any status or code check downstream sees the same thing it did before; the original error is kept ascause.InspectorClient: oneannotateRequestErrorapplies both the Report outstanding-request and notification-stream state on a request timeout, not a bare "Request timed out" #2318 timeout annotation and this one. It serves theclient.requestfunnel the timeout diagnostics already decorated (renamedannotateSdkRequestErrors) and the ext-tasks raw-wire channel that bypasses it (modern-era tool calls andtasks/*against a server advertising the tasks extension). So a request in any of the three clients gets it, with no per-call-sitecatch.Retry-Aftercomes from: the SDK'sSdkHttpErrorkeeps the status but drops every response header, so the header is read from the fetch tracker. ARateLimitLedgerfiles each 429's header under the JSON-RPC id(s) in its tracked request body.trackSendFailureis the one point that knows which message a failed send was carrying, and it stamps that id's header onto the error. So two requests throttled at once each report their own server'sRetry-After, whatever order the responses land in.RemoteSessionkeeps its own ledger, and the send route takes the header for the id it was sending and relaysretryAfteron the typed HTTP error (extending Web client: protocol era "auto" does not fall back to legacy when a 2025-only server rejects the probe with HTTP 400 #2660'sRemoteHttpErrorData). That way the browser doesn't depend on a fetch event arriving before the send's HTTP response, which travel over two different connections.Screenshots
Web client, Tools screen, connected to a fixture server that answers
tools/listwith429 Too Many RequestsandRetry-After: 30. Same fixture and build steps on both sides; only the source differs (origin/v2/mainvs this branch).Before: the generic SDK error, with nothing to say this is throttling:
After: named as rate limiting, with the throttled request and the server's retry timing:
The mid-word wrap (
HTTP 42/9) is the error box's existing monospace wrapping, unchanged by this PR.Scope
The issue asks for the 429 +
Retry-Aftersignal. Two things are deliberately left out:Retry-Afteris not treated as rate limiting. 503 means "unavailable", which is exactly the case the issue wants told apart from throttling.connectionDiagnostics.tstakes for timeouts.Tests
src/test/core/mcp/rateLimit.test.ts: every branch of the pure module.src/test/integration/mcp/inspectorClient-rate-limit.test.ts: a hand-rolled throttling server driven through the real Node transport (with a header, without one, a 503 left untouched) and through the real web backend + remote transport. Two more cases overlap two throttled requests (the first one's body is held back until the second has rejected) on both transports. They fail under the original "latest 429" behavior and pass with the id correlation. Two modern-era cases throttletools/callon a tasks-extension server, which takes the raw wire; they fail with the raw-wire hook reverted to timeout-only.npm run local:gatepasses.🤖 Generated with Claude Code