Skip to content

fix(plugins): rate limiter returns proper HTTP status codes and headers (#2668)#3449

Merged
crivetimihai merged 3 commits intomainfrom
2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2
Mar 10, 2026
Merged

fix(plugins): rate limiter returns proper HTTP status codes and headers (#2668)#3449
crivetimihai merged 3 commits intomainfrom
2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2

Conversation

@ja8zyjits
Copy link
Copy Markdown
Member

@ja8zyjits ja8zyjits commented Mar 3, 2026

🐛 Bug-fix PR

📌 Summary

Fixes #3560
Rate-limiting and other policy enforcement plugins needed the ability to return proper HTTP status codes (e.g., 429 Too Many Requests) and standard headers (e.g., Retry-After, X-RateLimit-*) when violations occur. Previously, all plugin violations returned HTTP 200 with JSON-RPC error payloads, making it impossible for:

  • Rate-limiting plugins to signal backoff periods to clients
  • Authorization plugins to return proper 403 Forbidden responses
  • Content moderation to return 422 Unprocessable Entity
  • Clients to implement proper retry logic based on HTTP semantics

This created a mismatch between HTTP-native clients expecting standard status codes and the gateway's JSON-RPC-centric error handling.


🔁 Reproduction Steps

  1. Start the gateway with config.yaml
    (e.g. make dev or python -m mcpgateway.main)

  2. Repeatedly call a REST tool until rate limits are exceeded

  3. Observe:

    • HTTP status
    • Response headers
    • JSON response body

🐞 Root Cause

Improper error code handling in plugin violation.

💡 Fix Description

This PR introduces a three-tier approach for determining HTTP status codes in plugin violation responses:

  1. Explicit Status (highest priority): Plugins can set http_status_code directly
  2. Code Mapping (fallback): Common violation codes map to appropriate HTTP statuses via PLUGIN_VIOLATION_CODE_MAPPING
  3. Default 200 (ultimate fallback): Maintains JSON-RPC compliance when no status is specified

Key Changes

1. Enhanced PluginViolation Model

class PluginViolation(BaseModel):
    # ... existing fields ...
    http_status_code: Optional[int] = None  # NEW: Explicit HTTP status
    http_headers: Optional[dict[str, str]] = None  # NEW: Custom headers (e.g., Retry-After)

2. Violation Code Mapping (mcpgateway/plugins/framework/constants.py)

PLUGIN_VIOLATION_CODE_MAPPING = {
    "RATE_LIMIT": 429,              # Rate limiting
    "INVALID_URI": 400,             # Bad request
    "PROTOCOL_BLOCKED": 403,        # Forbidden
    "CONTENT_TOO_LARGE": 413,       # Payload too large
    "CONTENT_MODERATION": 422,      # Unprocessable entity
    "PII_DETECTED": 422,            # Sensitive data
    "INVALID_TOKEN": 401,           # Unauthorized
    # ... 20+ mappings total
}

3. Updated Exception Handler (mcpgateway/main.py)

async def plugin_violation_exception_handler(_request: Request, exc: PluginViolationError):
    # Determine HTTP status: explicit → mapping → default 200
    http_status = exc.violation.http_status_code if exc.violation and exc.violation.http_status_code else None
    if not http_status:
        http_status = PLUGIN_VIOLATION_CODE_MAPPING.get(exc.violation.code, 200)

    # Add custom headers if provided
    headers = exc.violation.http_headers if exc.violation and exc.violation.http_headers else None
    response = ORJSONResponse(status_code=http_status, content={...})
    if headers:
        response.headers.update(headers)
    return response

🧪 Testing

Added 8 comprehensive test cases covering:

✅ Explicit http_status_code takes precedence over mapping
✅ Code mapping fallback (e.g., RATE_LIMIT → 429)
✅ Default to 200 for unknown codes (JSON-RPC compliance)
✅ HTTP headers properly propagated to response
✅ Multiple headers (Retry-After, X-RateLimit-*) included correctly
✅ Graceful handling when headers is None
✅ Backward compatibility with existing violations (no fields set)
✅ Doctest examples updated to reflect new behavior

Test Coverage: All new code paths covered, including edge cases.


End to End test

  1. In .env file update:
PLUGINS_ENABLED=true
RATE_LIMITER_ENABLED=true
PLUGINS_CONFIG_FILE=plugins/config.yaml
DEV_MODE=true
  1. In plugins/config.yaml update:
--- a/plugins/config.yaml
+++ b/plugins/config.yaml
@@ -208,20 +208,21 @@ plugins:

   # Rate limiter (fixed window, in-memory)
   - name: "RateLimiterPlugin"
+    enabled: true
     kind: "plugins.rate_limiter.rate_limiter.RateLimiterPlugin"
     description: "Per-user/tenant/tool rate limits"
     version: "0.1.0"
     author: "Mihai Criveti"
     hooks: ["prompt_pre_fetch", "tool_pre_invoke"]
     tags: ["limits", "throttle"]
-    mode: "disabled"
-    priority: 20
+    mode: "enforce"
+    priority: 5
     conditions: []
     config:
-      by_user: "60/m"
-      by_tenant: "600/m"
+      by_user: "6/m"
+      by_tenant: "6/m"
       by_tool:
-        search: "10/m"
+        search: "1/m"
  1. Also update your environment with the following changes:
$ export RATE_LIMITER_ENABLED=true
$ export RATE_LIMIT_DEFAULT=5/m
$ export LOG_LEVEL=DEBUG
$ export SERVER_URL=http://localhost:8000
  1. Create a JWT token and assign it to your TOKEN environment variable
export TOKEN="$(                                                                                                                                                                        ─╯
    python -m mcpgateway.utils.create_jwt_token \
        --username admin@example.com \
        --exp 10080 \
        --secret "$JWT_SECRET_KEY" \
        --algo HS256
)"
  1. Start the mcp gateway with make dev
  2. Once the gateway is ready, create a tool (this dummy tool does a rest call to an external api)
curl -X 'POST' \
  'http://localhost:8000/tools/' \
  -H 'accept: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
  "tool": {
    "allow_auto": false,
    "name": "dummy",
    "displayName": "Dummy API",
    "url": "https://fakerestapi.azurewebsites.net/api/v1/Users/1",
    "description": "Dummmy API for testing",
    "integration_type": "REST",
    "request_type": "GET",
    "headers": {
      "accept": "*/*"
    },
    "inputSchema": {},
    "outputSchema": {},
    "annotations": {},
    "jsonpath_filter": "",
    "visibility": "public",
    "expose_passthrough": true
  }
}'
  1. Call the tool repeatedly and check the response
curl -i -H "Authorization: Bearer $TOKEN" \                                                                                                                                             ─╯
    -H "Content-Type: application/json" \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
         "params":{"name":"dummy","arguments":{"num1":5,"num2":3}}}' \
    http://localhost:8000/rpc
  1. After 6 requests within a minute, you can see the the deny 422 responses with all the required headers
HTTP/1.1 429 Too Many Requests
date: Wed, 25 Feb 2026 22:20:33 GMT
server: uvicorn
content-length: 489
content-type: application/json
x-ratelimit-limit: 6
x-ratelimit-remaining: 0
x-ratelimit-reset: 1772058092
retry-after: 59
x-content-type-options: nosniff
x-frame-options: DENY
x-xss-protection: 0
x-download-options: noopen
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' data: https://cdnjs.cloudflare.com; connect-src 'self' ws: wss: https:; frame-ancestors 'none';
x-correlation-id: 0b9ff1098fbf4f11b4d68afc133d7926

{"error":{"code":-32602,"message":"Plugin Violation: Rate limit exceeded for tool dummy, user admin@example.com, or tenant default","data":{"description":"Rate limit exceeded for tool dummy, user admin@example.com, or tenant default","details":{"limited":true,"remaining":0,"reset_in":59,"dimensions":{"violated":[{"limited":true,"remaining":0,"reset_in":59},{"limited":true,"remaining":0,"reset_in":59}],"allowed":[]}},"plugin_error_code":"RATE_LIMIT","plugin_name":"RateLimiterPlugin"}}}

🔄 Backward Compatibility

✅ Fully Backward Compatible

  • New fields are optional (Optional[int], Optional[dict[str, str]])
  • Existing plugins without these fields continue to work (default to HTTP 200)
  • JSON-RPC error structure unchanged
  • No breaking changes to public APIs

📝 Example Usage

Rate-Limiting Plugin

return PluginViolation(
    reason="Rate limit exceeded",
    description="Too many requests from this client",
    code="RATE_LIMIT",
    http_status_code=429,  # Explicit status
    http_headers={
        "Retry-After": "60",
        "X-RateLimit-Limit": "100",
        "X-RateLimit-Remaining": "0",
        "X-RateLimit-Reset": "1737394800"
    }
)

Response:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1737394800

{
  "error": {
    "code": -32602,
    "message": "Plugin Violation: Too many requests from this client",
    "data": {
      "description": "Too many requests from this client",
      "plugin_error_code": "RATE_LIMIT",
      ...
    }
  }
}

🧪 Verification

Check Command Status
Lint suite make lint
Unit tests make test
Coverage ≥ 80 % make coverage
Manual regression no longer fails steps / screenshots

📐 MCP Compliance (if relevant)

  • Matches current MCP spec
  • No breaking change to MCP clients

✅ Checklist


This PR enables plugins to implement proper HTTP semantics for policy enforcement while maintaining full backward compatibility with the existing JSON-RPC error model.

@ja8zyjits ja8zyjits added bug Something isn't working plugins MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe python Python / backend development (FastAPI) release-fix Critical bugfix required for the release merge-queue Rebased and ready to merge labels Mar 3, 2026
@ja8zyjits
Copy link
Copy Markdown
Member Author

Recreated this because in the previous one, I had John pushing feature updates. This branch is only for bug fix and not feature.
Please dont push code into this.
This was the last one
#3383
and this was the one before that
#3183

Both were same issues.

@ja8zyjits ja8zyjits force-pushed the 2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2 branch from ec7c31d to 07fc1cd Compare March 3, 2026 18:45
msureshkumar88
msureshkumar88 previously approved these changes Mar 5, 2026
@crivetimihai crivetimihai added this to the Release 1.0.0-RC2 milestone Mar 5, 2026
@crivetimihai
Copy link
Copy Markdown
Member

Thanks @ja8zyjits — important fix for #2668. Enabling plugins to return proper HTTP status codes (429, 403, 422) is critical for client interop. The PR is substantial — make sure test coverage is thorough for all status code paths. LGTM on the approach.

@ja8zyjits
Copy link
Copy Markdown
Member Author

Hi @crivetimihai,
The tests coverage is 100% for the changes made. Only vulture is failing for non related reasons.

@ja8zyjits ja8zyjits force-pushed the 2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2 branch from a59a18b to 91f61eb Compare March 6, 2026 10:08
Copy link
Copy Markdown
Collaborator

@Lang-Akshay Lang-Akshay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work @ja8zyjits

Linked Issues — Completion Status (Ignore IP based anonymous request rate limiting as only authorized user have access )

Issue Requirement Status Notes
#2668 Return HTTP 429 for rate-limited requests ✅ Done Plugin sets http_status_code=429, handler propagates it
#2668 Add X-RateLimit-Limit header ✅ Done _make_headers() generates it
#2668 Add X-RateLimit-Remaining header ✅ Done _make_headers() generates it
#2668 Add X-RateLimit-Reset (unix epoch) header ✅ Done _make_headers() generates it
#2668 Add Retry-After header ✅ Done Included on violation path
#2668 Extend enforcement to MCP tools/endpoints ✅ Done tool_pre_invoke hook applies to all tool invocations (REST + MCP)
#2668 Support daily unit (/d) in _parse_rate() ❌ Missing _parse_rate() still only supports /s, /m, /h — no /d
#2668 IP-based limiting for anonymous requests ⚠️ Deferred Explicitly deferred to #3349

Overall coverage: 6 of 8 requirements addressed (1 missing, 1 deferred)


Here are few finding to strengthen the code

# Location Severity CWE Description
1 main.py:1449 🔴 High CWE-644 No header-name allowlist/denylist — _validate_http_headers() validates RFC 9110 syntax but does not restrict which headers a plugin may set. A malicious or buggy plugin can inject Set-Cookie (session fixation), Location (open redirect), Access-Control-Allow-Origin (CORS bypass), WWW-Authenticate (auth confusion), Content-Security-Policy (CSP override), etc.
2 main.py:1474 🟡 Medium CWE-117 Log injection via f-string — logger.warning(f"Invalid header name: {key}") interpolates plugin-supplied data, risking structured log corruption.
3 models.py:1363 🟡 Medium CWE-644 Dead PluginResult.http_headers field — declared on PluginResult and populated by rate limiter on success path, but no code reads it. Success-path headers silently dropped; future wiring without validation creates injection risk.
4 main.py:1573 🔵 Low Typo: validatated_headers → should be validated_headers
5 constants.py:13 🔵 Low Duplicate # Standard comment on consecutive lines

Remediation Suggestions

# Suggestion Effort
1 Add a header-name denylist (or preferably an allowlist: Retry-After, X-RateLimit-*, X-Plugin-*) in _validate_http_headers(). Ref: OWASP A05:2021, CWE-644. Low
2 Switch from f-strings to parameterized logging: logger.warning("Invalid header name: %s", key). Ref: CWE-117. Trivial
3 Remove http_headers from PluginResult until wired up, or add a clear TODO and ensure future wiring uses _validate_http_headers(). Trivial
4 Rename validatated_headersvalidated_headers. Trivial
5 Remove duplicate # Standard comment at constants.py:13. Trivial

@crivetimihai crivetimihai self-assigned this Mar 7, 2026
@ja8zyjits ja8zyjits force-pushed the 2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2 branch from 91f61eb to dae5c52 Compare March 9, 2026 11:52
@ja8zyjits
Copy link
Copy Markdown
Member Author

Hi @crivetimihai @msureshkumar88 @Lang-Akshay

I have created a new child issue from #2668 and have made sure that this PR is only directed to wards the new bug issue.
This will reduce all the confusions and limit the scope of the PR.

Regards,
Jitesh

@ja8zyjits ja8zyjits force-pushed the 2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2 branch 3 times, most recently from 96a4d75 to 434e77a Compare March 9, 2026 16:03
msureshkumar88
msureshkumar88 previously approved these changes Mar 9, 2026
ja8zyjits and others added 2 commits March 10, 2026 00:38
…lations

Add support for plugins to specify HTTP status codes (e.g., 429 for rate
limiting) and custom headers (e.g., Retry-After) in PluginViolation responses.

- Add http_status_code and http_headers fields to PluginViolation model
- Implement PLUGIN_VIOLATION_CODE_MAPPING for common violation types
- Update plugin_violation_exception_handler to use explicit status codes
  with fallback to code mapping, defaulting to 200 for JSON-RPC compliance
- Add RFC 9110 header validation to prevent header injection
- Enhance rate limiter plugin with multi-dimensional rate limiting,
  proper HTTP 429 responses, and X-RateLimit-* / Retry-After headers
- Add comprehensive test coverage for status code precedence, header
  propagation, and header validation

Fixes #2668

Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
- Fix typo: validatated_headers → validated_headers
- Hoist RFC 9110 token regex to module-level compiled constant
- Consolidate redundant CRLF check into unified CTL validation
- Fix integration test docstring path (was unit test path)
- Add tests for _parse_rate minute/hour/error branches
- Add tests for unlimited (no-limit) prompt and tool paths
- Achieve 100% differential test coverage on rate_limiter.py

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai crivetimihai force-pushed the 2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2 branch from 434e77a to d158a5e Compare March 10, 2026 00:42
@crivetimihai
Copy link
Copy Markdown
Member

Review — Rebased, reviewed, fixed, and pushed

Rebase

Rebased cleanly onto main, squashed 8 commits into 1 clean commit preserving original author + added a reviewer fixup commit on top.

Fixes applied

  1. Typo: validatated_headersvalidated_headers in mcpgateway/main.py
  2. Regex compiled on every call: Hoisted RFC 9110 token regex to module-level _RFC9110_TOKEN_RE constant
  3. Redundant CRLF check: The separate \r/\n check was a subset of the CTL character loop — consolidated with unified warning message
  4. Wrong docstring path: tests/integration/test_rate_limiter.py header said tests/unit/...
  5. Test coverage to 100%: Added tests for _parse_rate minute/hour/error branches and unlimited (no-limit) prompt/tool paths → plugins/rate_limiter/rate_limiter.py now at 100% coverage

Design review

The three-tier HTTP status resolution (explicit http_status_codePLUGIN_VIOLATION_CODE_MAPPING → default 200) is sound and backward compatible. Header validation properly prevents injection per RFC 9110. The VALID_HTTP_STATUS_CODES dict restricts to 4xx/5xx which is correct for violation responses.

Note for follow-up

PluginResult.http_headers is set by the rate limiter on successful (non-violation) responses, but the PluginManager and service layer don't propagate it to HTTP responses yet. Only PluginViolation.http_headers reaches the client (via the exception handler). The successful-response headers are a good foundation for a follow-up PR.

After the restructure into independent crates (#3147), each crate
has its own target/ directory. The existing pattern only covered the
workspace-level plugins_rust/target/.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed, rebased, and fixed. 100% differential test coverage on rate_limiter.py. Design is sound — three-tier HTTP status resolution is backward compatible, header validation prevents injection per RFC 9110. Minor fixups applied (typo, regex hoist, redundant check consolidation, docstring path). Note for follow-up: PluginResult.http_headers on successful responses is not yet propagated by the PluginManager/service layer.

@crivetimihai crivetimihai merged commit 2541b23 into main Mar 10, 2026
39 checks passed
@crivetimihai crivetimihai deleted the 2668-bug-follow-up-rate-limiter-incorrect-http-status-missing-headers-and-feature-gaps-refs-2397-2 branch March 10, 2026 07:44
@crivetimihai
Copy link
Copy Markdown
Member

@ja8zyjits - created [FEATURE]: Propagate PluginResult.http_headers to HTTP responses on successful requests #3576 as a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working merge-queue Rebased and ready to merge MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe plugins python Python / backend development (FastAPI) release-fix Critical bugfix required for the release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: Bug Report: HTTP 429 Status Code Not Returned for Rate-Limited Requests

4 participants