From b533f6a63da6a70573a43fbbf40e64b7cd662d20 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 09:11:20 +0300 Subject: [PATCH 1/2] feat(backend): add IP-based rate limiting middleware Adds a token-bucket rate limiter per client IP via golang.org/x/time/rate. Controlled by RATE_LIMIT_RPS and RATE_LIMIT_BURST env vars; disabled when RPS is unset or zero. Returns HTTP 429 when the per-IP bucket is exhausted. Also fixes stale source paths across backend/docs/ and adds middleware.md. Updates CLAUDE.md to require .env.example updates alongside new env vars. --- CLAUDE.md | 6 +- backend/.env.example | 4 +- backend/docs/_index.md | 7 +- backend/docs/environment.md | 8 +- backend/docs/error-handling.md | 8 +- backend/docs/middleware.md | 43 +++++++++ backend/docs/routing.md | 24 +++-- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/bootstrap/bootstrap.go | 24 +++-- backend/internal/server/server.go | 2 +- backend/internal/transport/handlers/routes.go | 5 +- .../transport/middleware/ratelimit.go | 39 ++++++++ .../transport/middleware/ratelimit_test.go | 92 +++++++++++++++++++ 14 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 backend/docs/middleware.md create mode 100644 backend/internal/transport/middleware/ratelimit.go create mode 100644 backend/internal/transport/middleware/ratelimit_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 4c38a17..6a6553e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,9 @@ make migrate-version # print current schema version 3. **Migrate** — if the feature needs new or changed tables: `make migrate-create name=`, write the SQL, `make migrate-up` 4. **Write tests first (TDD)** — write failing tests that describe the intended behaviour before writing any implementation code 5. **Implement** — delegate to `backend` or `web` agent, passing the relevant doc content as context; implementation is complete when all tests pass -6. **Update docs** — delegate to `docs` agent: update `last_verified`, add new topics if introduced -7. **Quality gate** — run `/project:check` before declaring done +6. **Env vars** — if any new env vars were added to `loadConfig()`, add them to `backend/.env.example` with a safe placeholder value and a comment explaining the format +7. **Update docs** — delegate to `docs` agent: update `last_verified`, add new topics if introduced +8. **Quality gate** — run `/project:check` before declaring done Use `/project:implement` to run this workflow end-to-end. @@ -199,3 +200,4 @@ cd backend && make swagger # regenerate docs/swagger/ after annotation changes - No database mocks in tests. - No `any` in TypeScript. - **No new feature without tests.** Every new route, component, utility function, use case, and Composable must ship with tests. +- **Every new env var must appear in `backend/.env.example`** with a safe placeholder value and a comment. Never leave a new `loadConfig()` variable undocumented in the example file. diff --git a/backend/.env.example b/backend/.env.example index 68cadb8..be70c82 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,4 +7,6 @@ BLUEPRINT_DB_USERNAME= BLUEPRINT_DB_PASSWORD= BLUEPRINT_DB_SCHEMA=public BLUEPRINT_DB_SSLMODE=disable -REDIS_URL= \ No newline at end of file +REDIS_URL= +RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled) +RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5) \ No newline at end of file diff --git a/backend/docs/_index.md b/backend/docs/_index.md index 8d476a2..b38d5bc 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -8,7 +8,8 @@ The `docs` agent reads this index first to locate the right file before diving i | Startup lifecycle & dependency initialisation | [bootstrap.md](bootstrap.md) | `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `cmd/api/main.go` | | Database connection & query patterns | [database.md](database.md) | `internal/infrastructure/database/postgres/db.go`, `internal/infrastructure/database/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` | | Schema migrations (goose) | [migrations.md](migrations.md) | `cmd/migrate/main.go`, `internal/infrastructure/database/migrations/`, `Makefile` | -| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/handler/handler.go`, `internal/handler/routes.go`, `internal/handler/hello_handler.go`, `internal/handler/health_handler.go`, `internal/server/server.go` | +| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/transport/handlers/handler.go`, `internal/transport/handlers/routes.go`, `internal/transport/handlers/hello_handler.go`, `internal/transport/handlers/health_handler.go`, `internal/transport/middleware/logger.go`, `internal/server/server.go` | | Testing patterns (unit, handler, Redis, bootstrap) | [testing.md](testing.md) | `internal/infrastructure/database/postgres/health_repository_test.go`, `internal/transport/handlers/hello_handler_test.go`, `internal/transport/handlers/health_handler_test.go`, `internal/transport/middleware/logger_test.go`, `internal/usecase/health_usecase_test.go`, `internal/infrastructure/cache/redis/cache_test.go`, `internal/bootstrap/bootstrap_test.go` | -| Error handling conventions | [error-handling.md](error-handling.md) | `internal/repository/postgres/health_repository.go`, `internal/handler/health_handler.go`, `cmd/api/main.go` | -| Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/repository/postgres/db.go` | +| Error handling conventions | [error-handling.md](error-handling.md) | `internal/infrastructure/database/postgres/health_repository.go`, `internal/transport/handlers/health_handler.go`, `cmd/api/main.go` | +| Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/infrastructure/database/postgres/db.go` | +| Middleware (logger, rate limiter) | [middleware.md](middleware.md) | `internal/transport/middleware/logger.go`, `internal/transport/middleware/ratelimit.go`, `internal/transport/handlers/routes.go` | diff --git a/backend/docs/environment.md b/backend/docs/environment.md index 1e0de7d..e119a74 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -1,10 +1,10 @@ --- topic: environment -last_verified: 2026-06-14 +last_verified: 2026-06-15 sources: - .env - internal/bootstrap/bootstrap.go - - internal/repository/postgres/db.go + - internal/infrastructure/database/postgres/db.go --- # Environment Variables @@ -21,7 +21,7 @@ This runs on package init before any env var is read — no explicit `godotenv.L | Variable | Used in | Default | Description | |---|---|---|---| | `PORT` | `bootstrap.go` | `8080` | HTTP server listen port | -| `APP_ENV` | `bootstrap.go` | — | Environment name (`local`, `production`) | +| `ENV` | `bootstrap.go` | — | Environment name (`local`, `production`) | | `BLUEPRINT_DB_HOST` | `bootstrap.go` | — | Postgres host (**required**) | | `BLUEPRINT_DB_PORT` | `bootstrap.go` | — | Postgres port (**required**) | | `BLUEPRINT_DB_DATABASE` | `bootstrap.go` | — | Database name (**required**) | @@ -29,6 +29,8 @@ This runs on package init before any env var is read — no explicit `godotenv.L | `BLUEPRINT_DB_PASSWORD` | `bootstrap.go` | — | Postgres password (**required**) | | `BLUEPRINT_DB_SCHEMA` | `bootstrap.go` | `public` | Postgres search_path schema | | `BLUEPRINT_DB_SSLMODE` | `bootstrap.go` | `disable` | Postgres SSL mode (`disable`, `require`, `verify-full`) | +| `RATE_LIMIT_RPS` | `bootstrap.go` | `0` (disabled) | Max requests per second per IP. Set to `0` or omit to disable rate limiting. | +| `RATE_LIMIT_BURST` | `bootstrap.go` | `RPS * 5` | Token-bucket burst capacity. Defaults to 5× RPS when omitted. | Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing. diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md index abd2ea6..56ea813 100644 --- a/backend/docs/error-handling.md +++ b/backend/docs/error-handling.md @@ -1,9 +1,9 @@ --- topic: error-handling -last_verified: 2026-06-14 +last_verified: 2026-06-15 sources: - - internal/repository/postgres/health_repository.go - - internal/handler/health_handler.go + - internal/infrastructure/database/postgres/health_repository.go + - internal/transport/handlers/health_handler.go - cmd/api/main.go --- @@ -85,4 +85,4 @@ if err := c.ShouldBindJSON(&input); err != nil { Use `fmt.Errorf("context: %w", err)` when adding context to returned errors so callers can use `errors.Is` / `errors.As`. ## Panic recovery -`gin.Default()` includes the Recovery middleware — panics in handlers are recovered and return 500. Do not rely on this; handle errors explicitly. +`gin.Recovery()` is applied explicitly in `RegisterRoutes()` — panics in handlers are recovered and return 500. Do not rely on this; handle errors explicitly. diff --git a/backend/docs/middleware.md b/backend/docs/middleware.md new file mode 100644 index 0000000..9bbc903 --- /dev/null +++ b/backend/docs/middleware.md @@ -0,0 +1,43 @@ +--- +topic: middleware +last_verified: 2026-06-15 +sources: + - internal/transport/middleware/logger.go + - internal/transport/middleware/ratelimit.go + - internal/transport/handlers/routes.go +--- + +# Middleware + +All middleware lives in `internal/transport/middleware/` and follows the Gin `HandlerFunc` pattern. Middleware is registered in `RegisterRoutes()` inside `internal/transport/handlers/routes.go`. + +## Registration order + +```go +// 1. Recovery + logger (debug: gin.Logger, release: middleware.Logger) +r.Use(gin.Recovery(), middleware.Logger()) +// 2. Rate limiter (no-op when RPS <= 0) +r.Use(middleware.RateLimit(rps, burst)) +// 3. CORS +r.Use(cors.New(...)) +``` + +## Logger + +`Logger() gin.HandlerFunc` emits one structured `slog` record per request after `c.Next()` returns. Fields: `status`, `method`, `path`, `latency`, `ip`, and optionally `query` and `errors`. + +In debug mode (`ENV` not set to `staging`/`production`) Gin's built-in colorful logger is used instead. + +## Rate limiter + +`RateLimit(rps float64, burst int) gin.HandlerFunc` limits each client IP to `rps` requests per second using a token-bucket algorithm (`golang.org/x/time/rate`). Each IP gets its own `rate.Limiter` stored in a mutex-guarded map. + +- **Disabled** when `rps <= 0` (no-op middleware returned). +- **429 Too Many Requests** returned when the bucket is empty; body: `{"error": "rate limit exceeded"}`. +- Configured via env vars `RATE_LIMIT_RPS` and `RATE_LIMIT_BURST` (see [environment](environment.md)). + +## Adding new middleware + +1. Create `internal/transport/middleware/.go` with a function returning `gin.HandlerFunc`. +2. Register it in `RegisterRoutes()` in `internal/transport/handlers/routes.go` at the appropriate position in the chain. +3. If it requires configuration, add fields to `bootstrap.Config` and read from env in `loadConfig()`. diff --git a/backend/docs/routing.md b/backend/docs/routing.md index 8f51093..844b867 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -1,11 +1,12 @@ --- topic: routing -last_verified: 2026-06-14 +last_verified: 2026-06-15 sources: - - internal/handler/handler.go - - internal/handler/routes.go - - internal/handler/hello_handler.go - - internal/handler/health_handler.go + - internal/transport/handlers/handler.go + - internal/transport/handlers/routes.go + - internal/transport/handlers/hello_handler.go + - internal/transport/handlers/health_handler.go + - internal/transport/middleware/logger.go - internal/server/server.go - cmd/api/main.go --- @@ -48,9 +49,20 @@ All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.H ```go func (h *Handler) RegisterRoutes() http.Handler { - r := gin.Default() + r := gin.New() + + // Gin's colorful logger locally; structured slog logger in staging/production. + if gin.Mode() == gin.DebugMode { + r.Use(gin.Recovery(), gin.Logger()) + } else { + r.Use(gin.Recovery(), middleware.Logger()) + } + r.Use(cors.New(cors.Config{ ... })) + r.GET("/path", h.myHandler) + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + return r } ``` diff --git a/backend/go.mod b/backend/go.mod index eee7f5a..35d232a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,7 @@ require ( github.com/swaggo/swag v1.16.6 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + golang.org/x/time v0.15.0 ) require ( diff --git a/backend/go.sum b/backend/go.sum index 4a5b51c..a124987 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -304,6 +304,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index f4c606d..963fa97 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -40,10 +40,12 @@ type App struct { // Config holds all validated configuration values read from environment variables. type Config struct { - Port int - Env string - DB postgres.DBConfig - RedisURL string + Port int + Env string + DB postgres.DBConfig + RedisURL string + RateLimitRPS float64 + RateLimitBurst int } // ConfigError is returned when required configuration is absent or invalid. @@ -125,10 +127,18 @@ func loadConfig() Config { sslMode = "disable" } + rps, _ := strconv.ParseFloat(os.Getenv("RATE_LIMIT_RPS"), 64) + burst, _ := strconv.Atoi(os.Getenv("RATE_LIMIT_BURST")) + if burst == 0 && rps > 0 { + burst = int(rps) * 5 + } + return Config{ - Port: port, - Env: os.Getenv("ENV"), - RedisURL: os.Getenv("REDIS_URL"), + Port: port, + Env: os.Getenv("ENV"), + RedisURL: os.Getenv("REDIS_URL"), + RateLimitRPS: rps, + RateLimitBurst: burst, DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index d24b278..5f9ccc5 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -32,7 +32,7 @@ func NewServer(app *bootstrap.App) *http.Server { return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), - Handler: h.RegisterRoutes(), + Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index 4e0a0d4..f302c24 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -13,7 +13,8 @@ import ( ) // RegisterRoutes creates the Gin engine, applies middleware, and registers all routes. -func (h *Handler) RegisterRoutes() http.Handler { +// rps and burst configure IP-based rate limiting; pass rps<=0 to disable. +func (h *Handler) RegisterRoutes(rps float64, burst int) http.Handler { r := gin.New() // Use Gin's colorful logger locally; structured slog logger in staging/production. @@ -23,6 +24,8 @@ func (h *Handler) RegisterRoutes() http.Handler { r.Use(gin.Recovery(), middleware.Logger()) } + r.Use(middleware.RateLimit(rps, burst)) + r.Use(cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:3000"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, diff --git a/backend/internal/transport/middleware/ratelimit.go b/backend/internal/transport/middleware/ratelimit.go new file mode 100644 index 0000000..dc74e16 --- /dev/null +++ b/backend/internal/transport/middleware/ratelimit.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +// RateLimit returns a Gin middleware that limits each client IP to rps requests per +// second with the given burst size. When rps <= 0 the middleware is a no-op. +func RateLimit(rps float64, burst int) gin.HandlerFunc { + if rps <= 0 { + return func(c *gin.Context) { c.Next() } + } + + var mu sync.Mutex + limiters := make(map[string]*rate.Limiter) + + getLimiter := func(ip string) *rate.Limiter { + mu.Lock() + defer mu.Unlock() + l, ok := limiters[ip] + if !ok { + l = rate.NewLimiter(rate.Limit(rps), burst) + limiters[ip] = l + } + return l + } + + return func(c *gin.Context) { + if !getLimiter(c.ClientIP()).Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) + return + } + c.Next() + } +} diff --git a/backend/internal/transport/middleware/ratelimit_test.go b/backend/internal/transport/middleware/ratelimit_test.go new file mode 100644 index 0000000..b4653fb --- /dev/null +++ b/backend/internal/transport/middleware/ratelimit_test.go @@ -0,0 +1,92 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "backend/internal/transport/middleware" + "github.com/gin-gonic/gin" +) + +func init() { gin.SetMode(gin.TestMode) } + +func newTestRouter(rps float64, burst int) *gin.Engine { + r := gin.New() + r.Use(middleware.RateLimit(rps, burst)) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + return r +} + +func TestRateLimit_Disabled(t *testing.T) { + // rps=0 → no-op, all requests pass + r := newTestRouter(0, 0) + for i := 0; i < 20; i++ { + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("request %d: got %d, want 200", i, rr.Code) + } + } +} + +func TestRateLimit_AllowsUnderLimit(t *testing.T) { + // burst=5, rps=100 → first 5 requests all pass (burst absorbs them) + r := newTestRouter(100, 5) + for i := 0; i < 5; i++ { + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("request %d: got %d, want 200", i, rr.Code) + } + } +} + +func TestRateLimit_BlocksOverLimit(t *testing.T) { + // burst=1, rps=1 → second request immediately blocked + r := newTestRouter(1, 1) + req1, _ := http.NewRequest("GET", "/", nil) + rr1 := httptest.NewRecorder() + r.ServeHTTP(rr1, req1) + if rr1.Code != http.StatusOK { + t.Fatalf("first request: got %d, want 200", rr1.Code) + } + + req2, _ := http.NewRequest("GET", "/", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + if rr2.Code != http.StatusTooManyRequests { + t.Fatalf("second request: got %d, want 429", rr2.Code) + } +} + +func TestRateLimit_PerIP(t *testing.T) { + // Each IP gets its own limiter; exhausting one IP does not affect another. + // RemoteAddr is what Gin's ClientIP() resolves to when no trusted proxy is + // configured (the default in tests), so set it directly to simulate distinct + // clients rather than relying on X-Forwarded-For which requires proxy trust. + r := newTestRouter(1, 1) + + sendFrom := func(ip string) int { + req, _ := http.NewRequest("GET", "/", nil) + req.RemoteAddr = ip + ":12345" + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + return rr.Code + } + + // Exhaust IP-A + if code := sendFrom("10.0.0.1"); code != http.StatusOK { + t.Fatalf("IP-A first request: got %d, want 200", code) + } + if code := sendFrom("10.0.0.1"); code != http.StatusTooManyRequests { + t.Fatalf("IP-A second request: got %d, want 429", code) + } + + // IP-B should still be allowed + if code := sendFrom("10.0.0.2"); code != http.StatusOK { + t.Fatalf("IP-B first request: got %d, want 200", code) + } +} From e707c4470c3cc5b48b1eccb3d9192df5b9bbf0ab Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Mon, 15 Jun 2026 09:25:02 +0300 Subject: [PATCH 2/2] fix(backend): address rate limiter review findings - Clamp burst to >=1 so fractional RATE_LIMIT_RPS (e.g. 0.1) never blocks all traffic (int truncation produced burst=0 previously) - Evict stale per-IP limiters every 5 minutes to bound memory growth - Add TestRateLimit_FractionalRPS_AllowsFirst to cover the burst clamp - Update routing.md snippet to reflect RegisterRoutes(rps, burst) signature - Clarify RATE_LIMIT_BURST env doc to document int truncation and min-1 clamp --- backend/docs/environment.md | 2 +- backend/docs/routing.md | 7 +++- .../transport/middleware/ratelimit.go | 38 +++++++++++++++++-- .../transport/middleware/ratelimit_test.go | 12 ++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/backend/docs/environment.md b/backend/docs/environment.md index e119a74..bf9354d 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -30,7 +30,7 @@ This runs on package init before any env var is read — no explicit `godotenv.L | `BLUEPRINT_DB_SCHEMA` | `bootstrap.go` | `public` | Postgres search_path schema | | `BLUEPRINT_DB_SSLMODE` | `bootstrap.go` | `disable` | Postgres SSL mode (`disable`, `require`, `verify-full`) | | `RATE_LIMIT_RPS` | `bootstrap.go` | `0` (disabled) | Max requests per second per IP. Set to `0` or omit to disable rate limiting. | -| `RATE_LIMIT_BURST` | `bootstrap.go` | `RPS * 5` | Token-bucket burst capacity. Defaults to 5× RPS when omitted. | +| `RATE_LIMIT_BURST` | `bootstrap.go` | `int(RPS) * 5`, min 1 | Token-bucket burst capacity. Derived as `int(RPS)*5` when omitted; clamped to 1 so fractional RPS values never block all traffic. | Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing. diff --git a/backend/docs/routing.md b/backend/docs/routing.md index 844b867..9c7eb92 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -37,7 +37,7 @@ h := handler.NewHandler(healthUC) return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), - Handler: h.RegisterRoutes(), + Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, @@ -46,9 +46,10 @@ return &http.Server{ ## Route registration All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`. +`rps` and `burst` come from `bootstrap.Config` (env vars `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST`); pass `rps=0` to disable. ```go -func (h *Handler) RegisterRoutes() http.Handler { +func (h *Handler) RegisterRoutes(rps float64, burst int) http.Handler { r := gin.New() // Gin's colorful logger locally; structured slog logger in staging/production. @@ -58,6 +59,8 @@ func (h *Handler) RegisterRoutes() http.Handler { r.Use(gin.Recovery(), middleware.Logger()) } + r.Use(middleware.RateLimit(rps, burst)) + r.Use(cors.New(cors.Config{ ... })) r.GET("/path", h.myHandler) diff --git a/backend/internal/transport/middleware/ratelimit.go b/backend/internal/transport/middleware/ratelimit.go index dc74e16..0511748 100644 --- a/backend/internal/transport/middleware/ratelimit.go +++ b/backend/internal/transport/middleware/ratelimit.go @@ -3,30 +3,62 @@ package middleware import ( "net/http" "sync" + "time" "github.com/gin-gonic/gin" "golang.org/x/time/rate" ) +const ( + limiterTTL = 10 * time.Minute + cleanupInterval = 5 * time.Minute +) + +type ipLimiter struct { + limiter *rate.Limiter + lastSeen time.Time +} + // RateLimit returns a Gin middleware that limits each client IP to rps requests per // second with the given burst size. When rps <= 0 the middleware is a no-op. +// Burst is clamped to a minimum of 1 so fractional rps values never block all traffic. +// Stale per-IP entries are evicted every 5 minutes. func RateLimit(rps float64, burst int) gin.HandlerFunc { if rps <= 0 { return func(c *gin.Context) { c.Next() } } + if burst < 1 { + burst = 1 + } var mu sync.Mutex - limiters := make(map[string]*rate.Limiter) + limiters := make(map[string]*ipLimiter) + + go func() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-limiterTTL) + mu.Lock() + for ip, l := range limiters { + if l.lastSeen.Before(cutoff) { + delete(limiters, ip) + } + } + mu.Unlock() + } + }() getLimiter := func(ip string) *rate.Limiter { mu.Lock() defer mu.Unlock() l, ok := limiters[ip] if !ok { - l = rate.NewLimiter(rate.Limit(rps), burst) + l = &ipLimiter{limiter: rate.NewLimiter(rate.Limit(rps), burst)} limiters[ip] = l } - return l + l.lastSeen = time.Now() + return l.limiter } return func(c *gin.Context) { diff --git a/backend/internal/transport/middleware/ratelimit_test.go b/backend/internal/transport/middleware/ratelimit_test.go index b4653fb..5493640 100644 --- a/backend/internal/transport/middleware/ratelimit_test.go +++ b/backend/internal/transport/middleware/ratelimit_test.go @@ -62,6 +62,18 @@ func TestRateLimit_BlocksOverLimit(t *testing.T) { } } +func TestRateLimit_FractionalRPS_AllowsFirst(t *testing.T) { + // rps=0.1 → int(0.1)*5 == 0; without the burst clamp this would block every request. + // With clamp to 1, the first request must pass. + r := newTestRouter(0.1, 0) + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("fractional rps first request: got %d, want 200", rr.Code) + } +} + func TestRateLimit_PerIP(t *testing.T) { // Each IP gets its own limiter; exhausting one IP does not affect another. // RemoteAddr is what Gin's ClientIP() resolves to when no trusted proxy is