feat: add flexible key extraction to rate limiter (#2896)#2991
feat: add flexible key extraction to rate limiter (#2896)#2991sreenivasivbieb wants to merge 3 commits intogofr-dev:developmentfrom
Conversation
- Add KeyExtractor type for custom rate limit key derivation - Implement built-in extractors: ExtractHeader, ExtractParam, ExtractBody, ExtractCombined, ExtractStatic, ExtractIP - Priority system: KeyExtractor > PerIP > Global - Maintain backward compatibility with PerIP flag - Add comprehensive test coverage (30+ tests) - Support use cases: API key, email, user ID, tenant-based rate limiting Closes gofr-dev#2896
There was a problem hiding this comment.
Pull request overview
This PR adds flexible key extraction capabilities to the rate limiter middleware, enabling rate limiting based on any request attribute (API keys, user IDs, email addresses, etc.) rather than only IP-based or global rate limiting. The implementation maintains full backward compatibility with the existing PerIP flag.
Changes:
- Introduced
KeyExtractorfunction type with built-in extractor functions (header, param, body, combined, static, IP) - Added priority-based key selection: KeyExtractor → PerIP → Global
- Implemented automatic fallback to IP-based rate limiting when key extraction fails, with metric tracking
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
pkg/gofr/http/middleware/rate_limiter.go |
Added KeyExtractor field to config and priority-based key extraction logic with fallback handling |
pkg/gofr/http/middleware/rate_limiter_extractors.go |
Implemented 7 built-in extractor functions with comprehensive error handling |
pkg/gofr/http/middleware/rate_limiter_extractors_test.go |
Added 30+ unit tests covering all extractor scenarios and edge cases |
pkg/gofr/http/middleware/rate_limiter_test.go |
Added 9 integration tests for backward compatibility, priority system, and custom extractors |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@coolwednesday, can you pls check my pr |
- Add 1MB body size limit in ExtractBody to prevent memory exhaustion attacks - Replace strings.NewReader with bytes.NewReader to avoid unnecessary allocation - Remove misleading ExtractPathParam function (was just an alias to ExtractParam) - Add security warnings to KeyExtractor documentation about bucket exhaustion attacks - Add panic protection in ExtractCombined when no extractors provided - Add test case for ExtractCombined edge case - Document security best practices for using extractors with user-controlled data
|
@coolwednesday I have made changes can you check the pr |
|
@Umang01-hash Can you suggest me the changes in code that i need to consider it would be useful to me |
|
Sure @sreenivasivbieb will review your PR shortly and tell you about any changes needed in this PR. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func ExtractParam(name string) KeyExtractor { | ||
| return func(r *http.Request) (string, error) { | ||
| value := r.URL.Query().Get(name) | ||
| if value == "" { | ||
| return "", ErrKeyNotFound | ||
| } | ||
| return value, nil | ||
| } | ||
| } |
There was a problem hiding this comment.
The PR description mentions ExtractPathParam as one of the built-in extractor functions, but this function is not implemented in the code. Either implement ExtractPathParam to extract values from URL path parameters (using gorilla/mux path variables), or remove the reference from the PR description.
|
|
||
| // Close the original body immediately as we'll replace it | ||
| originalBody := r.Body | ||
| if closeErr := originalBody.Close(); closeErr != nil && err == nil { | ||
| err = closeErr | ||
| } | ||
|
|
||
| if err != nil { |
There was a problem hiding this comment.
When body reading fails (line 103), the original body is closed (line 106-109) but never restored before returning the error (line 111-112). This means subsequent handlers will receive a request with a closed body, which could cause issues. Consider restoring the body even in error cases, perhaps with an empty reader, to maintain predictable behavior for downstream handlers.
| // Close the original body immediately as we'll replace it | |
| originalBody := r.Body | |
| if closeErr := originalBody.Close(); closeErr != nil && err == nil { | |
| err = closeErr | |
| } | |
| if err != nil { | |
| // Close the original body immediately as we'll replace it | |
| originalBody := r.Body | |
| if closeErr := originalBody.Close(); closeErr != nil && err == nil { | |
| err = closeErr | |
| } | |
| if err != nil { | |
| // Restore body with an empty reader so downstream handlers | |
| // see a valid (but empty) body instead of a closed one. | |
| r.Body = io.NopCloser(bytes.NewReader(nil)) |
| return func(r *http.Request) (string, error) { | ||
| // Only work with JSON content | ||
| contentType := r.Header.Get("Content-Type") | ||
| if !strings.Contains(contentType, "application/json") { |
There was a problem hiding this comment.
Using strings.Contains for Content-Type matching is inconsistent with the codebase convention and could lead to false positives. The codebase uses strings.Split(contentType, ";")[0] followed by exact matching (see pkg/gofr/http/request.go:60). Consider following the same pattern here to properly handle Content-Type headers with charset parameters and avoid unintended matches.
| if !strings.Contains(contentType, "application/json") { | |
| mediaType := strings.Split(contentType, ";")[0] | |
| if strings.TrimSpace(mediaType) != "application/json" { |
| if config.KeyExtractor != nil { | ||
| key, keyErr = config.KeyExtractor(r) | ||
| if keyErr != nil { | ||
| // If key extraction fails, use a fallback | ||
| // Log the error but don't fail the request | ||
| if m != nil { | ||
| m.IncrementCounter(r.Context(), "app_http_rate_limit_key_extraction_failed", | ||
| "path", r.URL.Path, "error", keyErr.Error()) | ||
| } | ||
| // Use IP as fallback | ||
| key = getIP(r, config.TrustedProxies) | ||
| if key == "" { | ||
| key = "unknown" | ||
| } | ||
| } |
There was a problem hiding this comment.
When KeyExtractor succeeds (err is nil) but returns an empty string, the empty string is used as the rate limit key. This could cause all requests to share the same bucket unintentionally. Consider validating that the extracted key is non-empty when err is nil, and falling back to IP if the key is empty.
|
|
||
| // ErrNoExtractors is returned when ExtractCombined is called with no extractors. | ||
| ErrNoExtractors = errors.New("no extractors provided") |
There was a problem hiding this comment.
The error variable ErrNoExtractors is defined but never used in the codebase. The ExtractCombined function panics instead of returning this error when no extractors are provided. Consider either removing this unused error or changing ExtractCombined to return this error instead of panicking for more graceful error handling.
| // ErrNoExtractors is returned when ExtractCombined is called with no extractors. | |
| ErrNoExtractors = errors.New("no extractors provided") |
|
@sreenivasivbieb Since you requested a review from CoPilot lets address its given comments if needed else lets close them so that i can review the PR. Let me know once you have fixed code quality issues, resolved copilot's comment and updated the branch, i will review the PR. |
Description
This PR implements flexible key extraction for the rate limiter middleware, addressing issue #2896. The enhancement allows developers to rate limit based on any request attribute (API keys, user IDs, email addresses, tenant IDs, etc.) rather than being limited to IP-based or global rate limiting.
Key Changes
1. New KeyExtractor Type
KeyExtractorfunction type that extracts rate limiting keys from HTTP requests(key string, err error)to support flexible key derivation with error handling2. Built-in Extractor Functions (
rate_limiter_extractors.go)ExtractHeader(name)- Rate limit by HTTP header (e.g., API keys, auth tokens)ExtractParam(name)- Rate limit by query parameter (e.g., user_id)ExtractPathParam(name)- Rate limit by URL path parameterExtractBody(field)- Rate limit by JSON body field (e.g., email in login requests)ExtractCombined(extractors...)- Combine multiple extractors with fallback chainExtractStatic(key)- Use a static key for global limits with custom namingExtractIP(trustProxies)- Extract IP address (alternative to PerIP flag)3. Priority System
4. Robust Error Handling
app_http_rate_limit_key_extraction_failedmetric5. Comprehensive Test Coverage
Use Cases Enabled
Breaking Changes
None. This PR maintains full backward compatibility:
PerIPflag continues to work as beforeKeyExtractornorPerIPis set, global rate limiting is used (unchanged behavior)KeyExtractorfield is optional and defaults tonilImplementation Details
Files Created
rate_limiter_extractors.go- Extractor implementations (233 lines)rate_limiter_extractors_test.go- Extractor tests (213 lines)Files Modified
rate_limiter.go- Added KeyExtractor field and priority logicrate_limiter_test.go- Added 9 integration testsAdditional Information
net/http,encoding/json,io,strings)Checklist
goimportsandgolangci-lintCloses #2896