Summary
StreamableHTTPClientTransport does not handle HTTP 429 Too Many Requests responses. When a server responds with 429 + Retry-After, the client throws a generic SdkError instead of waiting the indicated interval and retrying. Clients that don't add their own retry logic therefore fail-fast on any rate limit.
Current behaviour
In packages/client/src/client/streamableHttp.ts (main, commit-of-today):
- Explicit status handling exists for 401/403 (OAuth re-auth), 405 (no GET stream), 202 (accepted), SSE disconnect (exponential backoff +
retry: field).
- 429 falls through to the generic
!response.ok path around line 633 and surfaces as a plain SdkError with status/statusText only.
- Neither
Retry-After nor the IETF draft-standard RateLimit-* headers (RFC 9331) are read.
Grep for 429 in the file returns no hits.
Impact
- Servers that implement reasonable rate limits (e.g.
express-rate-limit) always return 429 with Retry-After on excess traffic. Every client that speaks to such a server crashes on overload instead of backing off.
- The spec (
docs/specification/.../basic/transports.mdx across 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, draft) does not say anything about rate-limit headers, so today each server/client pair has to reinvent this. The missing client behaviour is the sharp end of that gap.
- Observed in the wild with a server plugin (MiYo Kado MCP Gateway, https://github.com/MMoMM-org/miyo-kado): server emits
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After on 429; clients built on this SDK still fail rather than retry. Kado ships a client-side reference retry loop in test/live/mcp-live.test.ts (probeRetryAfter() + callTool()) as a workaround.
Reproduction
Minimal repro against any MCP server behind a rate limiter:
- Call
tools/call repeatedly above the configured rate (e.g. 200 req/min).
- Server returns 429 with
Retry-After: <seconds> and standard RateLimit-* headers.
- Client code awaiting
transport.send(...) receives a thrown SdkError immediately. No retry, no waiting.
Expected: the transport waits at least Retry-After seconds (or falls back to a bounded exponential backoff when the header is missing) and retries the same request once or twice before surfacing the error.
Suggested direction
Similar in spirit to #1370 (incorporate OAuth retry logic into the SDK):
- On 429, parse
Retry-After (HTTP-date or delta-seconds per RFC 7231 §7.1.3). If absent, fall back to exponential backoff. Optionally also honour RateLimit-Reset (RFC 9331) as the wait hint.
- Wait, then retry the original request up to a small, configurable number of attempts.
- Surface a clear error only after retries are exhausted, ideally including the parsed
Retry-After value so callers can decide what to do next.
- Allow opting out via a constructor option for clients that want to handle rate limiting themselves.
Happy to prepare a PR if there is interest and directional agreement.
Environment
Summary
StreamableHTTPClientTransportdoes not handle HTTP429 Too Many Requestsresponses. When a server responds with 429 +Retry-After, the client throws a genericSdkErrorinstead of waiting the indicated interval and retrying. Clients that don't add their own retry logic therefore fail-fast on any rate limit.Current behaviour
In
packages/client/src/client/streamableHttp.ts(main, commit-of-today):retry:field).!response.okpath around line 633 and surfaces as a plainSdkErrorwith status/statusText only.Retry-Afternor the IETF draft-standardRateLimit-*headers (RFC 9331) are read.Grep for
429in the file returns no hits.Impact
express-rate-limit) always return 429 withRetry-Afteron excess traffic. Every client that speaks to such a server crashes on overload instead of backing off.docs/specification/.../basic/transports.mdxacross 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, draft) does not say anything about rate-limit headers, so today each server/client pair has to reinvent this. The missing client behaviour is the sharp end of that gap.RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset, andRetry-Afteron 429; clients built on this SDK still fail rather than retry. Kado ships a client-side reference retry loop intest/live/mcp-live.test.ts(probeRetryAfter()+callTool()) as a workaround.Reproduction
Minimal repro against any MCP server behind a rate limiter:
tools/callrepeatedly above the configured rate (e.g. 200 req/min).Retry-After: <seconds>and standardRateLimit-*headers.transport.send(...)receives a thrownSdkErrorimmediately. No retry, no waiting.Expected: the transport waits at least
Retry-Afterseconds (or falls back to a bounded exponential backoff when the header is missing) and retries the same request once or twice before surfacing the error.Suggested direction
Similar in spirit to #1370 (incorporate OAuth retry logic into the SDK):
Retry-After(HTTP-date or delta-seconds per RFC 7231 §7.1.3). If absent, fall back to exponential backoff. Optionally also honourRateLimit-Reset(RFC 9331) as the wait hint.Retry-Aftervalue so callers can decide what to do next.Happy to prepare a PR if there is interest and directional agreement.
Environment
modelcontextprotocol/typescript-sdk, branchmain, filepackages/client/src/client/streamableHttp.ts