Skip to content

feat(client): honor Retry-After on HTTP 429 responses#1965

Open
MukundaKatta wants to merge 3 commits into
modelcontextprotocol:mainfrom
MukundaKatta:feat/honor-retry-after-429
Open

feat(client): honor Retry-After on HTTP 429 responses#1965
MukundaKatta wants to merge 3 commits into
modelcontextprotocol:mainfrom
MukundaKatta:feat/honor-retry-after-429

Conversation

@MukundaKatta

Copy link
Copy Markdown

Why

Closes #1892. StreamableHTTPClientTransport falls through 429 to a generic SdkError, so any client talking to a server that rate-limits with 429 Too Many Requests (e.g. the express-rate-limit default, the MiYo Kado MCP gateway, Cloudflare-fronted servers) crashes on the very first throttled response instead of waiting and retrying. Every consumer of this SDK has to reinvent client-side rate-limit handling.

What

  • Parse the Retry-After header on 429 (delta-seconds and HTTP-date forms per RFC 7231 §7.1.3) inside both the POST _send path and the GET _startOrAuthSse path.
  • Sleep for the indicated duration (clamped to a configurable ceiling) and retry the original request, up to a configurable number of times.
  • Fall back to a configurable defaultRetryAfterMs when the header is missing or unparsable.
  • After the cap is hit, throw a typed SdkErrorCode.ClientHttpRateLimited with the original Retry-After value attached to error.data so callers can react.
  • New rateLimitOptions transport option (maxRetries, defaultRetryAfterMs, maxRetryAfterMs). Defaults: 3 retries, 1s fallback, 60s cap. Set maxRetries: 0 to opt out entirely.
  • Honours the existing AbortController so transport.close() cancels in-flight Retry-After sleeps.

Tested

  • vitest unit tests in packages/client/test/client/streamableHttp.test.ts covering:
    • numeric Retry-After: 2 waits ~2s then succeeds
    • HTTP-date Retry-After is parsed and respected
    • missing Retry-After falls back to defaultRetryAfterMs
    • oversized Retry-After is clamped to maxRetryAfterMs
    • 4 consecutive 429s (3 retries) throw ClientHttpRateLimited
    • maxRetries: 0 disables retries
    • static _parseRetryAfter helper across all input shapes (null, undefined, empty, garbage, integer, fractional, future/past HTTP-date)
  • Existing StreamableHTTPClientTransport test suite still passes (no logic changes outside the new 429 branch).

StreamableHTTPClientTransport now treats 429 Too Many Requests as a
retryable signal. When the server returns 429 the transport parses the
Retry-After header (delta-seconds or HTTP-date per RFC 7231 §7.1.3),
waits for the indicated duration (clamped to a configurable ceiling),
and retries the original POST/GET. After a configurable number of
consecutive 429 responses the transport surfaces a typed
SdkErrorCode.ClientHttpRateLimited so callers can react.

Behaviour is configurable via the new rateLimitOptions transport
option. Setting maxRetries to 0 disables automatic retries entirely
for applications that want to handle rate limiting themselves.

Closes modelcontextprotocol#1892.
@MukundaKatta MukundaKatta requested a review from a team as a code owner April 26, 2026 21:47
@changeset-bot

changeset-bot Bot commented Apr 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0f97364

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Apr 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1965

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@1965

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1965

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@1965

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1965

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@1965

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1965

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1965

commit: 0f97364

The Record<string, fn> index returns fn | undefined under
noUncheckedIndexedAccess, so calling parse(...) raised TS2722 (cannot invoke
possibly-undefined). Add a non-null assertion on the helper lookup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client transport: HTTP 429 responses are not retried with Retry-After

1 participant