Skip to content

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

@MMoMM-org

Description

@MMoMM-org

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:

  1. Call tools/call repeatedly above the configured rate (e.g. 200 req/min).
  2. Server returns 429 with Retry-After: <seconds> and standard RateLimit-* headers.
  3. 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):

  1. 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.
  2. Wait, then retry the original request up to a small, configurable number of attempts.
  3. Surface a clear error only after retries are exhausted, ideally including the parsed Retry-After value so callers can decide what to do next.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge casesenhancementRequest for a new feature that's not currently supportedneeds decisionIssue is actionable, needs maintainer decision on whether to implement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions