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:
2026-05-03 13:16:13 +02:00
parent 9cf6e7f1c4
commit e1af61e1ea
10 changed files with 667 additions and 5 deletions

View File

@@ -16,6 +16,7 @@ type StepContext struct {
commonSteps *CommonSteps
jwtRetentionSteps *JWTRetentionSteps
configSteps *ConfigSteps
rateLimitSteps *RateLimitSteps
}
// NewStepContext creates a new step context
@@ -28,6 +29,7 @@ func NewStepContext(client *testserver.Client) *StepContext {
commonSteps: NewCommonSteps(client),
jwtRetentionSteps: NewJWTRetentionSteps(client),
configSteps: NewConfigSteps(client),
rateLimitSteps: NewRateLimitSteps(client),
}
}
@@ -62,6 +64,9 @@ func SetScenarioKeyForAllSteps(sc *StepContext, key string) {
if sc.commonSteps != nil {
sc.commonSteps.SetScenarioKey(key)
}
if sc.rateLimitSteps != nil {
sc.rateLimitSteps.SetScenarioKey(key)
}
}
}
@@ -294,6 +299,15 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
ctx.Step(`^the audit entry should contain the previous and new values$`, sc.configSteps.theAuditEntryShouldContainThePreviousAndNewValues)
ctx.Step(`^the audit entry should contain the timestamp of the change$`, sc.configSteps.theAuditEntryShouldContainTheTimestampOfTheChange)
// Rate limit steps
ctx.Step(`^the server is running with rate limit set to (\d+) requests per minute and burst (\d+)$`, sc.rateLimitSteps.theServerIsRunningWithRateLimitSetTo)
ctx.Step(`^I make (\d+) requests to "([^"]*)"$`, sc.rateLimitSteps.iMakeNRequestsTo)
ctx.Step(`^all responses should have status (\d+)$`, sc.rateLimitSteps.allResponsesShouldHaveStatus)
ctx.Step(`^I make 1 more request to "([^"]*)"$`, sc.rateLimitSteps.iMakeOneMoreRequestTo)
ctx.Step(`^the response should have status (\d+)$`, sc.rateLimitSteps.theResponseShouldHaveStatus)
ctx.Step(`^the response body should contain "([^"]*)"$`, sc.rateLimitSteps.theResponseBodyShouldContain)
ctx.Step(`^the response should have header "([^"]*)"$`, sc.rateLimitSteps.theResponseShouldHaveHeader)
// Common steps
ctx.Step(`^the response should be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.commonSteps.theResponseShouldBe)
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError)