Skip to content

SEP-3304 - Standardizing Rate-Limiting Errors - #3304

Open
tyree731 wants to merge 3 commits into
modelcontextprotocol:mainfrom
tyree731:rate-limiting
Open

SEP-3304 - Standardizing Rate-Limiting Errors#3304
tyree731 wants to merge 3 commits into
modelcontextprotocol:mainfrom
tyree731:rate-limiting

Conversation

@tyree731

Copy link
Copy Markdown

Abstract

This SEP standardizes on -32023 / RateLimited from the reserved sub-range, with a typed response indicating when to retry after, with optional quota fields, binding the response to the HTTP 429 to additionally allow for middleware
proxies to detect this.

Motivation

There are a couple of reasons for this specification. stdio has no HTTP layer: For the stdio protocol, no header
exchange can occur at present, meaning that relying on the HTTP layer to communicate a rate limit being hit cannot work
with the stdio protocol. The Transports WG, in the current roadmap, has committed to changing the stdio protocol to
speak Streamable HTTP, but there is no timeframe for this as of now.

In addition, the current SDK implementations do not define rate limiting errors, and are inconsistent in how they
surface HTTP 429 errors:

How Each SDK Surfaces an HTTP 429

SDK HTTP 429 Surfaces As Status Preserved Source
TypeScript SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Error POSTing to endpoint: ...") Yes, as status on the error object streamableHttp.ts#L1101
Python ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") (-32603) No, collapsed to an internal error streamable_http.py#L370
C# HttpRequestException with the response body appended to the message Yes, via HttpRequestException.StatusCode HttpResponseMessageExtensions.cs#L22
Rust StreamableHttpError::UnexpectedServerResponse("HTTP 429 Too Many Requests: <body>") Formatted text only streamable_http_client.rs#L302
Java McpTransportException("Invalid request. Status code: 429") Message string only HttpClientStreamableHttpTransport.java#L700
Go Classified as transient alongside 500/502/503/504 and wrapped as jsonrpc2.ErrRejected, so the connection is preserved http.StatusText only streamable.go#L2824, streamable.go#L2580
Kotlin StreamableHttpError(code = 429, message = body) Yes, as a numeric code property StreamableHttpClientTransport.kt#L195
PHP Not detected. send() branches only on Content-Type and never inspects the status code, so a 429 with a non-JSON body is silently dropped No HttpTransport.php#L165
Ruby Falls to the Faraday::Error catch-all, raising RequestHandlerError(error_type: :internal_error) Only via original_error.response[:status] http.rb#L509
Swift MCPError.internalError("Too many requests") No, mapped to a fixed string HTTPClientTransport.swift#L418

This inconsistency means that clients cannot reliably report or detect rate limits being hit across implementations.

How Has This Been Tested?

TODO.

Breaking Changes

The error code being used here is within the range of reserved codes for the MCP specification, so no existing client
should be relying on it. Older clients will see the same error shape they have previously with HTTP 429 errors, so only
clients which have implemented custom request and response handling using HTTP 429 errors will need to consider the new response shape.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Is this an error?

Yes. For much the same reason that InvalidParamsError is an error, namely that the client has made a mistake which
it needs to respond to, RateLimitedError communicates that the client has requested the server too rapidly, and needs
to back off appropriately.

Why milliseconds for retryAfterMs?

The Retry-After HTTP header supports both a decimal integer for its value, in addition to an HTTP date, so it's worth asking why we would use milliseconds here. The rationale is that other SEPs have standardized on milliseconds for their post-dated timings, such as SEP-2549, so we do so for consistency.

@tyree731 tyree731 changed the title SEP-0000 - Standardizing Rate-Limiting Errors SEP-3304 - Standardizing Rate-Limiting Errors Aug 25, 2026
@tyree731
tyree731 requested review from a team as code owners August 25, 2026 19:05
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.

1 participant