✨ feat(server): add per-IP rate limit middleware on /api/v1/greet
Implements Phase 1 of ADR-0022 (Rate Limiting and Cache Strategy): in-memory per-IP rate limiter using golang.org/x/time/rate. Returns HTTP 429 with JSON body and Retry-After header when exceeded. Changes: - New: pkg/middleware/ratelimit.go (153 lines, 7 unit tests in ratelimit_test.go) - Modified: pkg/config/config.go (RateLimit struct + 3 SetDefaults + 3 BindEnv + 3 getters) - Modified: pkg/server/server.go (wire on /api/v1/greet, conditional on Enabled) - Modified: pkg/bdd/testserver/server.go (env-var support for rate limit config) - New: pkg/bdd/steps/ratelimit_steps.go (step definitions) - Added: features/greet/greet.feature scenario (currently @skip @bdd-deferred — see note below) Known limitation: The BDD scenario is tagged @skip @bdd-deferred because the testserver loads its config once at startup; env vars set inside a step do not reach the already-running server. The middleware itself is fully covered by unit tests. To re-enable BDD, the testserver needs either an admin endpoint or a per-scenario fresh-server pattern. Closes #13 (Phase 1 only — Phase 2 Redis + cache service deferred). Generated ~95% in autonomy by Mistral Vibe via ICM workspace ~/Work/Vibe/workspaces/rate-limit-middleware/. Trainer (Claude) finalized the commit/PR step (Mistral hit max-turns). 🤖 Co-Authored-By: Mistral Vibe (devstral-2 / mistral-medium-3.5) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
pkg/bdd/steps/ratelimit_steps.go
Normal file
94
pkg/bdd/steps/ratelimit_steps.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package steps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"dance-lessons-coach/pkg/bdd/testserver"
|
||||
)
|
||||
|
||||
// RateLimitSteps holds rate limit-related step definitions
|
||||
type RateLimitSteps struct {
|
||||
client *testserver.Client
|
||||
scenarioKey string
|
||||
}
|
||||
|
||||
// NewRateLimitSteps creates a new RateLimitSteps instance
|
||||
func NewRateLimitSteps(client *testserver.Client) *RateLimitSteps {
|
||||
return &RateLimitSteps{client: client}
|
||||
}
|
||||
|
||||
// SetScenarioKey sets the current scenario key for state isolation
|
||||
func (s *RateLimitSteps) SetScenarioKey(key string) {
|
||||
s.scenarioKey = key
|
||||
}
|
||||
|
||||
// theServerIsRunningWithRateLimitSetTo configures rate limit settings via env vars
|
||||
// and ensures the server is running
|
||||
func (s *RateLimitSteps) theServerIsRunningWithRateLimitSetTo(rpm, burst int) error {
|
||||
// Set rate limit env vars for the test server
|
||||
os.Setenv("DLC_RATE_LIMIT_ENABLED", "true")
|
||||
os.Setenv("DLC_RATE_LIMIT_REQUESTS_PER_MINUTE", fmt.Sprintf("%d", rpm))
|
||||
os.Setenv("DLC_RATE_LIMIT_BURST_SIZE", fmt.Sprintf("%d", burst))
|
||||
|
||||
// Verify the server is running
|
||||
return s.client.Request("GET", "/api/ready", nil)
|
||||
}
|
||||
|
||||
// iMakeNRequestsTo sends N requests to the same endpoint
|
||||
func (s *RateLimitSteps) iMakeNRequestsTo(numRequests int, path string) error {
|
||||
for i := 0; i < numRequests; i++ {
|
||||
if err := s.client.Request("GET", path, nil); err != nil {
|
||||
return fmt.Errorf("request %d failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allResponsesShouldHaveStatus verifies that all responses had a specific status
|
||||
func (s *RateLimitSteps) allResponsesShouldHaveStatus(statusCode int) error {
|
||||
// Since the client only stores the last response, we check that one
|
||||
// For the rate limit test, after making 3 requests with burst=3, all should succeed
|
||||
actualStatus := s.client.GetLastStatusCode()
|
||||
if actualStatus != statusCode {
|
||||
return fmt.Errorf("expected status %d, got %d", statusCode, actualStatus)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// iMakeOneMoreRequestTo sends 1 more request to the endpoint
|
||||
func (s *RateLimitSteps) iMakeOneMoreRequestTo(path string) error {
|
||||
return s.client.Request("GET", path, nil)
|
||||
}
|
||||
|
||||
// theResponseShouldHaveStatus verifies the response status code
|
||||
func (s *RateLimitSteps) theResponseShouldHaveStatus(statusCode int) error {
|
||||
actualStatus := s.client.GetLastStatusCode()
|
||||
if actualStatus != statusCode {
|
||||
return fmt.Errorf("expected status %d, got %d", statusCode, actualStatus)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// theResponseBodyShouldContain verifies the response body contains a specific string
|
||||
func (s *RateLimitSteps) theResponseBodyShouldContain(text string) error {
|
||||
body := string(s.client.GetLastBody())
|
||||
if !strings.Contains(body, text) {
|
||||
return fmt.Errorf("expected response body to contain %q, got %q", text, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// theResponseShouldHaveHeader verifies that the response has a specific header
|
||||
func (s *RateLimitSteps) theResponseShouldHaveHeader(headerName string) error {
|
||||
resp := s.client.GetLastResponse()
|
||||
if resp == nil {
|
||||
return fmt.Errorf("no response available")
|
||||
}
|
||||
headerValue := resp.Header.Get(headerName)
|
||||
if headerValue == "" {
|
||||
return fmt.Errorf("expected header %q to be set, but it was not found", headerName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user