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>
95 lines
3.1 KiB
Go
95 lines
3.1 KiB
Go
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
|
|
}
|