Skip to content

feat(ratelimit): add authentication rate limiting - #8

Merged
Jonathan0823 merged 8 commits into
masterfrom
feat/rate-limitting-on-auth
Jul 31, 2026
Merged

Jonathan0823 merged 8 commits into
masterfrom
feat/rate-limitting-on-auth

Conversation

@Jonathan0823

@Jonathan0823 Jonathan0823 commented Jul 31, 2026 •

Copy link
Copy Markdown
Owner

Related issue

  • None linked.

Summary

  • Add configurable memory, Redis, and PostgreSQL rate-limit backends.
  • Enforce limits across login, registration, password reset, email verification, refresh, and OAuth flows.
  • Add HMAC-protected keys, trusted proxy handling, fail-closed backend behavior, safe 429/503 responses, audit events, and metrics.
  • Add migrations, Redis CI services, race detection, and Sonar integration coverage reporting.

Scope

In scope

  • Authentication-specific IP, account/email, token, and provider policies.
  • Shared Redis/PostgreSQL backends with memory support for development or approved single-instance deployments.
  • Safe operational configuration and integration coverage.

Out of scope

  • CAPTCHA, MFA, WAF integration, general API rate limiting, persistent audit storage, Grafana, Loki, and OpenTelemetry tracing.

Testing

  • go test ./...
  • go test -race ./...
  • go vet ./...
  • make integration
  • make coverage
  • make sqlc
  • git diff --check

All tests passed with PostgreSQL and Redis configured.

Reviewer focus

  • Atomic and concurrent behavior across all limiter backends.
  • Fail-closed behavior and absence of automatic memory fallback.
  • Trusted proxy and HMAC key handling.
  • Endpoint policy mapping and successful-login counter resets.
  • Generic responses and safe audit/metric fields.

Risks and migrations

  • Adds migration 000003_rate_limits for rate_limit_buckets.
  • Production requires a shared backend (redis or postgres) and RATE_LIMIT_KEY.
  • Redis requires REDIS_ADDR, REDIS_PASSWORD, and REDIS_DB.
  • Apply migrations with make migrate-up.

Summary by CodeRabbit

  • New Features

    • Added configurable rate limiting for authentication and OAuth flows.
    • Added memory, PostgreSQL, and Redis backend support.
    • Rate-limited requests now provide retry guidance; backend failures return a service-unavailable response.
    • Added trusted-proxy handling and protected rate-limit keys.
    • Added audit events and Prometheus metrics for rate-limit activity and backend failures.
  • Documentation

    • Updated setup, configuration, testing, Redis, and coverage documentation.
  • Tests

    • Expanded unit, integration, concurrency, race-detector, and coverage testing.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request adds configurable authentication rate limiting across HTTP handlers, memory, PostgreSQL, and Redis stores. It adds startup validation, trusted-proxy handling, audit and Prometheus events, integration tests, Redis CI services, and coverage tooling.

Changes

Authentication rate limiting

Layer / File(s) Summary
Contracts and policy configuration
internal/core/port/*, internal/platform/rate_limit*, .env.example
Defines rate-limit policies, store contracts, configuration validation, trusted proxies, and HMAC-protected keys.
Storage backends
internal/adapter/outbound/ratelimit/*, internal/adapter/outbound/postgres/*, migrations/*
Adds memory, PostgreSQL, and Redis stores with reset, expiration, concurrency, cleanup, and integration-test support.
Startup and observability
internal/bootstrap/*, internal/platform/audit.go, internal/platform/metrics.go
Selects and initializes the backend, configures trusted proxies, injects the limiter, and records normalized audit and Prometheus events.
HTTP enforcement
internal/adapter/inbound/http/*
Applies rate limits to authentication and OAuth endpoints and returns 429 or 503 responses when required.
Local, CI, and coverage support
.github/workflows/ci.yml, docker-compose.yml, Makefile, README.md, sonar-project.properties, go.mod
Adds Redis services, race tests, integration settings, coverage generation, the Redis dependency, and related documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AuthHandler
  participant RateLimiter
  participant RateLimitStore
  participant AuditLogger
  Client->>AuthHandler: Send authentication request
  AuthHandler->>RateLimiter: Check configured policies
  RateLimiter->>RateLimitStore: Allow hashed key
  RateLimitStore-->>RateLimiter: Return decision
  RateLimiter-->>AuthHandler: Return allowance or retry duration
  AuthHandler->>AuditLogger: Record denial or backend failure
  AuthHandler-->>Client: Continue, 429, or 503
Loading

Possibly related PRs

  • Jonathan0823/auth-go#1: Introduces the handlers, bootstrap, configuration, and PostgreSQL components extended by this rate-limiting change.
  • Jonathan0823/auth-go#6: Adds audit, metrics, configuration, and handler infrastructure extended with rate-limit events.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: configurable authentication rate limiting.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rate-limitting-on-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (7)
internal/platform/metrics_test.go (1)

17-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the emitted rate-limit dimensions.

The metric test passes if all labels normalize to unknown. Assert route, policy, backend, outcome, and reason labels. Add coverage for EventRateLimiterUnavailable.

Set Route in TestAuditLoggerUsesSafeFields. Assert the normalized route attribute in the audit record.

Also applies to: 54-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platform/metrics_test.go` around lines 17 - 38, Strengthen
TestAuditLoggerRecordsRateLimitMetric by asserting the emitted metric’s route,
policy, backend, outcome, and reason labels retain their expected normalized
values rather than all becoming unknown, and add coverage for
EventRateLimiterUnavailable. In TestAuditLoggerUsesSafeFields, provide a Route
value and assert the audit record contains the normalized route attribute.
internal/platform/metrics.go (1)

130-146: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Keep normalizeRoute and middleware metric routes in sync.

normalizeRoute maps every enforced rate-limit path to a known label, but internal/adapter/inbound/http/middleware/metrics.go also normalizes MiddlewareRequestCount routes and is not covered by the current test. Add a table-driven test for both metric route normalizations, using the registered Gin paths that actually receive metrics logging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platform/metrics.go` around lines 130 - 146, Add a table-driven test
covering normalizeRoute and the middleware’s MiddlewareRequestCount route
normalization, using the registered Gin paths that emit metrics. Include every
enforced rate-limit path and assert known labels for supported routes plus
“unknown” for unsupported paths, keeping both normalization implementations
synchronized.
internal/platform/redis.go (1)

5-11: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add timeout and TLS configuration options for Redis.

NewRedisClient only sets Addr, Password, and DB. Many managed Redis deployments require TLS, and missing DialTimeout, ReadTimeout, or WriteTimeout lets the rate-limit path hang on a stalled Redis connection.

Add TLS support behind an optional configuration flag and wire in reasonable connection/read/write timeouts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platform/redis.go` around lines 5 - 11, Update NewRedisClient to
configure reasonable dial, read, and write timeouts in redis.Options, and add
TLS configuration controlled by an optional configuration flag. Preserve the
existing address, password, and database settings, enabling
redis.Options.TLSConfig only when the flag requests TLS.
go.mod (1)

16-16: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bump github.com/redis/go-redis/v9 from v9.7.0.

v9.7.0 is several major-minor versions behind the latest v9 releases. Later v9 fixes include connection-pool turn-management handling and Options.clone() use-after-free behavior, both relevant to a request-path rate limiter that uses Redis. Upgrade within v9 to a current release and run the existing unit/race/integration tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go.mod` at line 16, Update the github.com/redis/go-redis/v9 dependency in
go.mod from v9.7.0 to the current compatible v9 release, refresh the module
checksums as needed, and run the existing unit, race, and integration tests to
verify the request-path rate limiter remains compatible.
internal/bootstrap/ratelimit.go (1)

11-23: 🧹 Nitpick | 🔵 Trivial

Consider a startup Redis health check.

platform.NewRedisClient does not verify connectivity before newRateLimitStore returns. Since go-redis connects lazily, a misconfigured or unreachable Redis instance surfaces only on the first request (as a 503 from the rate limiter), not at boot.

Add a Ping call after client construction and fail fast on error, consistent with the fail-closed design goal for this feature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/bootstrap/ratelimit.go` around lines 11 - 23, Update
newRateLimitStore’s "redis" branch to synchronously Ping the client returned by
platform.NewRedisClient before constructing or returning the Redis store; if
Ping fails, return the error with nil store/client values so startup fails fast,
while preserving existing behavior for successful connections and other
backends.
internal/platform/rate_limiter.go (1)

33-49: 🎯 Functional Correctness | 🔵 Trivial

Unreachable multi-check RetryAfter aggregation.

The loop returns as soon as one check is denied (Line 46-48). This means the decision.RetryAfter > retryAfter comparison at Line 43 never sees more than one denied check's value before the function returns. The aggregation logic implies intent to evaluate all checks and return the worst-case wait time, but the early return prevents that.

This shares one root cause with checks[0].Policy in internal/adapter/inbound/http/rate_limit.go's logRateLimitEvent: bundling several checks into one Allow(checks...) call removes the caller's ability to know which specific check triggered a denial. See the consolidated comment for the recommended fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/platform/rate_limiter.go` around lines 33 - 49, Update the check
loop in the rate limiter’s Allow flow to evaluate every policy before returning,
tracking whether any decision was denied and retaining the maximum RetryAfter
value. Return the aggregated denial decision only after all checks complete,
while preserving immediate propagation of backend errors and valid all-allowed
behavior.
internal/adapter/inbound/http/oauth_handler.go (1)

22-27: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add provider-scoped OAuth rate limiting if OAuth quotas need protection.

OAuthLogin and OAuthCallback only check ipRateLimit("oauth_ip", c), while the OAuth routes already split by :provider. If separating GitHub and Google request counts is needed to protect upstream OAuth limits, add a separate per-provider rate limit path in addition to the IP check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/adapter/inbound/http/oauth_handler.go` around lines 22 - 27, Add
provider-scoped rate limiting to the OAuth flow, using the provider parameter in
OAuthLogin and the corresponding OAuthCallback path in addition to the existing
IP-based check. Reuse the established rate-limit mechanism and ensure requests
are rejected when either the IP or provider quota is exceeded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docker-compose.yml`:
- Around line 17-24: Add a host-to-container port mapping under the redis
service so host-side integration and coverage tests can reach Redis at
localhost:6379, while preserving the existing healthcheck and Redis image
configuration.
- Around line 38-40: Remove the REDIS_ADDR, REDIS_PASSWORD, and REDIS_DB entries
from the production docker-compose configuration so values loaded from .env.prod
remain authoritative. If local Redis defaults are needed, move these settings to
a separate local-only Compose override.

In `@internal/adapter/inbound/http/rate_limit.go`:
- Around line 29-74: The allowRateLimit method currently bundles checks,
preventing accurate denial attribution; iterate through each check and call
RateLimiter.Allow individually, stopping at the first denial or backend error
and logging that specific check. Update
internal/adapter/inbound/http/rate_limit.go lines 29-74 accordingly. In
internal/platform/rate_limiter.go lines 33-49, simplify the multi-check
RetryAfter aggregation in Allow or document that its multi-check path is no
longer used, without changing single-check behavior.

In `@internal/adapter/outbound/ratelimit/memory.go`:
- Around line 35-51: Remove the full s.entries expiry sweep from
MemoryStore.Allow and keep expiration handling limited to the requested key;
also move evictOldest’s map-wide scan out of request processing where
applicable. Add a periodic background cleanup loop, following
PostgresStore.cleanupLoop, to reclaim expired entries, and update
MemoryStore.Close to stop and await that goroutine so shutdown does not leak
resources.

In `@internal/bootstrap/bootstrap.go`:
- Around line 47-54: Update the deferred shutdown calls after newRateLimitStore
in the bootstrap flow to use small defer functions that invoke
rateLimitStore.Close and redisClient.Close, check each returned error, and log
any shutdown failures through the existing logger. Preserve the conditional
redisClient cleanup and ensure both backend close errors are surfaced.

In `@Makefile`:
- Around line 11-12: Update the coverage recipe that runs go test to create the
parent directory of the configured COVERAGE_FILE before writing coverage output,
rather than relying only on COVERAGE_DIR. Preserve support for the default
COVERAGE_FILE and independently overridden paths.

---

Nitpick comments:
In `@go.mod`:
- Line 16: Update the github.com/redis/go-redis/v9 dependency in go.mod from
v9.7.0 to the current compatible v9 release, refresh the module checksums as
needed, and run the existing unit, race, and integration tests to verify the
request-path rate limiter remains compatible.

In `@internal/adapter/inbound/http/oauth_handler.go`:
- Around line 22-27: Add provider-scoped rate limiting to the OAuth flow, using
the provider parameter in OAuthLogin and the corresponding OAuthCallback path in
addition to the existing IP-based check. Reuse the established rate-limit
mechanism and ensure requests are rejected when either the IP or provider quota
is exceeded.

In `@internal/bootstrap/ratelimit.go`:
- Around line 11-23: Update newRateLimitStore’s "redis" branch to synchronously
Ping the client returned by platform.NewRedisClient before constructing or
returning the Redis store; if Ping fails, return the error with nil store/client
values so startup fails fast, while preserving existing behavior for successful
connections and other backends.

In `@internal/platform/metrics_test.go`:
- Around line 17-38: Strengthen TestAuditLoggerRecordsRateLimitMetric by
asserting the emitted metric’s route, policy, backend, outcome, and reason
labels retain their expected normalized values rather than all becoming unknown,
and add coverage for EventRateLimiterUnavailable. In
TestAuditLoggerUsesSafeFields, provide a Route value and assert the audit record
contains the normalized route attribute.

In `@internal/platform/metrics.go`:
- Around line 130-146: Add a table-driven test covering normalizeRoute and the
middleware’s MiddlewareRequestCount route normalization, using the registered
Gin paths that emit metrics. Include every enforced rate-limit path and assert
known labels for supported routes plus “unknown” for unsupported paths, keeping
both normalization implementations synchronized.

In `@internal/platform/rate_limiter.go`:
- Around line 33-49: Update the check loop in the rate limiter’s Allow flow to
evaluate every policy before returning, tracking whether any decision was denied
and retaining the maximum RetryAfter value. Return the aggregated denial
decision only after all checks complete, while preserving immediate propagation
of backend errors and valid all-allowed behavior.

In `@internal/platform/redis.go`:
- Around line 5-11: Update NewRedisClient to configure reasonable dial, read,
and write timeouts in redis.Options, and add TLS configuration controlled by an
optional configuration flag. Preserve the existing address, password, and
database settings, enabling redis.Options.TLSConfig only when the flag requests
TLS.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e61a6ff-9442-4d5c-809f-888a7ebf9bd6

📥 Commits

Reviewing files that changed from the base of the PR and between 9bf9232 and 14bd7b0.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (36)
  • .env.example
  • .github/workflows/ci.yml
  • .gitignore
  • Makefile
  • README.md
  • docker-compose.yml
  • go.mod
  • internal/adapter/inbound/http/auth_handler.go
  • internal/adapter/inbound/http/handler.go
  • internal/adapter/inbound/http/oauth_handler.go
  • internal/adapter/inbound/http/rate_limit.go
  • internal/adapter/inbound/http/rate_limit_test.go
  • internal/adapter/outbound/postgres/db/schema.sql
  • internal/adapter/outbound/postgres/models.go
  • internal/adapter/outbound/ratelimit/contract_test.go
  • internal/adapter/outbound/ratelimit/memory.go
  • internal/adapter/outbound/ratelimit/memory_test.go
  • internal/adapter/outbound/ratelimit/postgres.go
  • internal/adapter/outbound/ratelimit/postgres_integration_test.go
  • internal/adapter/outbound/ratelimit/redis.go
  • internal/adapter/outbound/ratelimit/redis_integration_test.go
  • internal/bootstrap/bootstrap.go
  • internal/bootstrap/ratelimit.go
  • internal/core/port/rate_limiter.go
  • internal/platform/audit.go
  • internal/platform/config.go
  • internal/platform/metrics.go
  • internal/platform/metrics_test.go
  • internal/platform/rate_limit_config.go
  • internal/platform/rate_limit_config_test.go
  • internal/platform/rate_limiter.go
  • internal/platform/rate_limiter_test.go
  • internal/platform/redis.go
  • migrations/000003_rate_limits.down.sql
  • migrations/000003_rate_limits.up.sql
  • sonar-project.properties

Comment thread docker-compose.yml
Comment on lines +17 to +24
redis:
image: redis:7-alpine
container_name: auth-go-redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Expose Redis for host-side integration tests.

README.md Line 35 starts the Compose Redis service, and the example configuration uses REDIS_ADDR=localhost:6379. This service has no host port mapping, so host-side make integration and make coverage runs cannot reach Redis.

Proposed fix
   redis:
     image: redis:7-alpine
     container_name: auth-go-redis
+    ports:
+      - "6379:6379"
     healthcheck:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
redis:
image: redis:7-alpine
container_name: auth-go-redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
container_name: auth-go-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 5
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 17 - 24, Add a host-to-container port
mapping under the redis service so host-side integration and coverage tests can
reach Redis at localhost:6379, while preserving the existing healthcheck and
Redis image configuration.

Comment thread docker-compose.yml Outdated
Comment on lines +29 to +74
func (h *Handler) allowRateLimit(c *gin.Context, checks ...platform.RateLimitCheck) bool {
if h.RateLimiter == nil {
return true
}
decision, err := h.RateLimiter.Allow(c.Request.Context(), checks...)
if err != nil {
h.logRateLimitEvent(c, platform.EventRateLimiterUnavailable, platform.OutcomeFailure, platform.ReasonBackendUnavailable, checks)
c.Header("Retry-After", retryAfterHeader(backendRetryAfter))
c.Header("Cache-Control", "no-store")
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "authentication temporarily unavailable"})
return false
}
if decision.Allowed {
return true
}

h.logRateLimitEvent(c, platform.EventAuthRateLimited, platform.OutcomeDetected, platform.ReasonRateLimited, checks)
c.Header("Retry-After", retryAfterHeader(decision.RetryAfter))
c.Header("Cache-Control", "no-store")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"})
return false
}

func (h *Handler) resetRateLimit(c *gin.Context, check platform.RateLimitCheck) {
if h.RateLimiter == nil {
return
}
if err := h.RateLimiter.Reset(c.Request.Context(), check); err != nil {
h.logRateLimitEvent(c, platform.EventRateLimiterUnavailable, platform.OutcomeFailure, platform.ReasonBackendUnavailable, []platform.RateLimitCheck{check})
}
}

func (h *Handler) logRateLimitEvent(c *gin.Context, event, outcome, reason string, checks []platform.RateLimitCheck) {
if h.Audit == nil || len(checks) == 0 {
return
}
h.Audit.Log(c.Request.Context(), platform.AuditEvent{
Name: event,
Outcome: outcome,
RequestID: requestID(c),
Reason: reason,
Route: c.FullPath(),
Policy: checks[0].Policy,
Backend: h.RateLimiter.Backend(),
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Bundling multiple checks into one Allow call breaks audit attribution and leaves aggregation logic unreachable. Both symptoms share one root cause: allowRateLimit passes every check for a handler (for example, IP and email) to a single Allow(checks...) call, so neither the caller nor Allow itself can identify which specific check was denied.

  • internal/adapter/inbound/http/rate_limit.go#L29-L74: change allowRateLimit to call h.RateLimiter.Allow(ctx, check) once per check in a loop, stopping and logging at the first denial, so logRateLimitEvent records the policy of the check that actually failed instead of always using checks[0].Policy.
  • internal/platform/rate_limiter.go#L33-L49: once Allow is only ever invoked with a single check (per the fix above), the decision.RetryAfter > retryAfter aggregation across multiple denied checks becomes unreachable in practice; simplify the loop accordingly or leave a comment noting the multi-check path is unused, to avoid confusing future maintainers about its intended behavior.
📍 Affects 2 files
  • internal/adapter/inbound/http/rate_limit.go#L29-L74 (this comment)
  • internal/platform/rate_limiter.go#L33-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/adapter/inbound/http/rate_limit.go` around lines 29 - 74, The
allowRateLimit method currently bundles checks, preventing accurate denial
attribution; iterate through each check and call RateLimiter.Allow individually,
stopping at the first denial or backend error and logging that specific check.
Update internal/adapter/inbound/http/rate_limit.go lines 29-74 accordingly. In
internal/platform/rate_limiter.go lines 33-49, simplify the multi-check
RetryAfter aggregation in Allow or document that its multi-check path is no
longer used, without changing single-check behavior.

Comment on lines +35 to +51
func (s *MemoryStore) Allow(ctx context.Context, key string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) {
if err := ctx.Err(); err != nil {
return port.RateLimitDecision{}, err
}
if policy.Limit < 1 || policy.Window <= 0 {
return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable
}

now := s.now()
s.mu.Lock()
defer s.mu.Unlock()

for storedKey, entry := range s.entries {
if !now.Before(entry.started.Add(entry.window)) {
delete(s.entries, storedKey)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Move full-map expiry sweep off the request hot path.

Allow scans the entire s.entries map on every call while holding s.mu (Lines 47-51). This scan is not needed for correctness: the per-key check at Lines 52-59 already handles both the missing-key and expired-key cases for the requested key on its own. The sweep only reclaims memory for unrelated keys, but it does so under lock on every single request, turning an O(1) hot path into an O(n) scan (bounded by maxKeys) that serializes all Allow/Reset calls on the store.

evictOldest (Lines 86-98) has the same O(n) shape and runs whenever a new key arrives at capacity, compounding cost during bursts of new distinct keys.

Move periodic reclamation into a background goroutine, similar to PostgresStore.cleanupLoop in postgres.go (Lines 126-140), with a corresponding shutdown hook in Close() to avoid a goroutine leak.

♻️ Proposed fix to move expiry reclamation off the hot path
 type MemoryStore struct {
 	mu      sync.Mutex
 	entries map[string]memoryEntry
 	maxKeys int
 	now     func() time.Time
+	cancel  context.CancelFunc
+	wg      sync.WaitGroup
 }
 
 func NewMemoryStore(maxKeys int) *MemoryStore {
 	if maxKeys < 1 {
 		maxKeys = 10_000
 	}
-	return &MemoryStore{
+	ctx, cancel := context.WithCancel(context.Background())
+	store := &MemoryStore{
 		entries: make(map[string]memoryEntry),
 		maxKeys: maxKeys,
 		now:     time.Now,
+		cancel:  cancel,
 	}
+	store.wg.Add(1)
+	go store.cleanupLoop(ctx)
+	return store
 }
 
 func (s *MemoryStore) Allow(ctx context.Context, key string, policy port.RateLimitPolicy) (port.RateLimitDecision, error) {
 	if err := ctx.Err(); err != nil {
 		return port.RateLimitDecision{}, err
 	}
 	if policy.Limit < 1 || policy.Window <= 0 {
 		return port.RateLimitDecision{}, port.ErrRateLimitBackendUnavailable
 	}
 
 	now := s.now()
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	for storedKey, entry := range s.entries {
-		if !now.Before(entry.started.Add(entry.window)) {
-			delete(s.entries, storedKey)
-		}
-	}
 	entry, exists := s.entries[key]
@@
-func (s *MemoryStore) Close() error { return nil }
+func (s *MemoryStore) Close() error {
+	if s.cancel != nil {
+		s.cancel()
+		s.wg.Wait()
+	}
+	return nil
+}
+
+func (s *MemoryStore) cleanupLoop(ctx context.Context) {
+	defer s.wg.Done()
+	ticker := time.NewTicker(time.Minute)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			now := s.now()
+			s.mu.Lock()
+			for storedKey, entry := range s.entries {
+				if !now.Before(entry.started.Add(entry.window)) {
+					delete(s.entries, storedKey)
+				}
+			}
+			s.mu.Unlock()
+		}
+	}
+}

Also applies to: 86-98

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/adapter/outbound/ratelimit/memory.go` around lines 35 - 51, Remove
the full s.entries expiry sweep from MemoryStore.Allow and keep expiration
handling limited to the requested key; also move evictOldest’s map-wide scan out
of request processing where applicable. Add a periodic background cleanup loop,
following PostgresStore.cleanupLoop, to reclaim expired entries, and update
MemoryStore.Close to stop and await that goroutine so shutdown does not leak
resources.

Comment on lines +47 to +54
rateLimitStore, redisClient, err := newRateLimitStore(cfg.RateLimit, pool)
if err != nil {
log.Fatal("rate-limit backend is unavailable")
}
defer rateLimitStore.Close()
if redisClient != nil {
defer redisClient.Close()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "---- ratelimit/redis.go ----"
fd -a redis.go internal/adapter/outbound/ratelimit --exec cat -n {}
echo "---- platform/redis.go ----"
fd -a redis.go internal/platform --exec cat -n {}

Repository: Jonathan0823/auth-go

Length of output: 3691


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "---- internal/bootstrap/bootstrap.go relevant section ----"
sed -n '1,90p' internal/bootstrap/bootstrap.go | cat -n
echo "---- all Close() usages near rate-limit/bootstrap ----"
rg -n "newRateLimitStore|RateLimitStore|Red.?is?Store|\\.Close\\(" internal/bootstrap/internal/adapter/outbound/ratelimit internal/platform 2>/dev/null || true

Repository: Jonathan0823/auth-go

Length of output: 4134


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "---- find rate limit store implementations ----"
git ls-files | rg 'internal/adapter/outbound/ratelimit|internal/platform|internal/bootstrap' | rg '(rate|redis|bootstrap)' || true
echo "---- usages of newRateLimitStore ----"
rg -n "newRateLimitStore|func newRateLimitStore|interface|New(Redis|Memory|Rate)Store|Close\\(\\) error" .

Repository: Jonathan0823/auth-go

Length of output: 4456


Log the unchecked Close error returns.

Both deferred shutdown calls ignore error returns; wrap them in small defer functions and log any errors so backend shutdown failures surface.

🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 51-51: Error return value of rateLimitStore.Close is not checked

(errcheck)


[error] 53-53: Error return value of redisClient.Close is not checked

(errcheck)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/bootstrap/bootstrap.go` around lines 47 - 54, Update the deferred
shutdown calls after newRateLimitStore in the bootstrap flow to use small defer
functions that invoke rateLimitStore.Close and redisClient.Close, check each
returned error, and log any shutdown failures through the existing logger.
Preserve the conditional redisClient cleanup and ensure both backend close
errors are surfaced.

Source: Linters/SAST tools

Comment thread Makefile
Comment on lines +11 to +12
COVERAGE_DIR ?= coverage
COVERAGE_FILE ?= $(COVERAGE_DIR)/coverage.out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Create the parent directory for COVERAGE_FILE.

COVERAGE_FILE can be overridden independently from COVERAGE_DIR, but the recipe creates only COVERAGE_DIR. make COVERAGE_FILE=artifacts/coverage.out coverage fails when artifacts/ does not exist.

Create the configured file's parent directory before running go test.

Proposed fix
 coverage: migrate-up
-	mkdir -p "$(COVERAGE_DIR)"
+	mkdir -p "$(COVERAGE_DIR)" "$(dir $(COVERAGE_FILE))"
 	go test -tags=integration ./... -covermode=atomic -coverprofile="$(COVERAGE_FILE)"

Also applies to: 22-24

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 11 - 12, Update the coverage recipe that runs go test
to create the parent directory of the configured COVERAGE_FILE before writing
coverage output, rather than relying only on COVERAGE_DIR. Preserve support for
the default COVERAGE_FILE and independently overridden paths.

@Jonathan0823
Jonathan0823 merged commit bcfadb5 into master Jul 31, 2026
9 of 10 checks passed
@Jonathan0823
Jonathan0823 deleted the feat/rate-limitting-on-auth branch July 31, 2026 06:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Line 22: Update the RATE_LIMIT_KEY configuration validation to reject the
default placeholder value change-me-rate-limit-key when running in production,
while continuing to require a non-empty deployment-specific random value. Locate
the existing startup validation for RATE_LIMIT_KEY and add this production-only
check without changing development behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 10e2da59-8ffc-428c-b993-bde23df3ff08

📥 Commits

Reviewing files that changed from the base of the PR and between 14bd7b0 and 845f96c.

📒 Files selected for processing (4)
  • .env.example
  • docker-compose.yml
  • internal/adapter/outbound/ratelimit/postgres.go
  • internal/platform/rate_limit_config.go
💤 Files with no reviewable changes (1)
  • docker-compose.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/adapter/outbound/ratelimit/postgres.go

Comment thread .env.example
# Authentication rate limiting
# Options: memory, redis, postgres.
RATE_LIMIT_BACKEND=memory
RATE_LIMIT_KEY=change-me-rate-limit-key

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 6 'RATE_LIMIT_KEY|change-me-rate-limit-key|production' internal/platform internal/bootstrap cmd

Repository: Jonathan0823/auth-go

Length of output: 7039


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- candidate files ---\n'
git ls-files | rg '(^|/)(rate_limit_config\.go|bootstrap|main\.go|Dockerfile|docker-compose|compose\.ya?ml|\.env\.example$)' || true

printf '\n--- rate_limit_config.go ---\n'
sed -n '1,140p' internal/platform/rate_limit_config.go

printf '\n--- usage of Validate and environment loading ---\n'
rg -n -C 4 'Validate\(|LoadRateLimitConfig|\bEnvironment\b|\bENVIRONMENT\b|RATE_LIMIT_KEY|change-me-rate-limit-key' -g '*.go' -g 'Dockerfile' -g 'docker-compose*.yml' -g 'compose*.ya?ml' -g '*.yaml' -g '*.yml' .

Repository: Jonathan0823/auth-go

Length of output: 249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(rate_limit_config\.go|bootstrap|main\.go|Dockerfile|docker-compose|compose\.ya?ml|\.env\.example$)' || true

printf '%s\n' ''
printf '%s\n' '--- rate_limit_config.go ---'
sed -n '1,140p' internal/platform/rate_limit_config.go

printf '%s\n' ''
printf '%s\n' '--- usage of Validate and environment loading ---'
grep -RInE 'Validate\(|LoadRateLimitConfig|\b(Environment|EnvironmentFunc|Environment)\b|\b(ENVIRONMENT|MODE|APP_ENV)\b|RATE_LIMIT_KEY|change-me-rate-limit-key' -g '*.go' -g 'Dockerfile' -g 'docker-compose*.yml' -g 'compose*.ya?ml' -g '*.yaml' -g '*.yml' . || true

Repository: Jonathan0823/auth-go

Length of output: 5230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- cmd/auth-go/main.go ---'
sed -n '1,220p' cmd/auth-go/main.go

echo ''
echo '--- internal/bootstrap/ratelimit.go ---'
sed -n '1,180p' internal/bootstrap/ratelimit.go

echo ''
echo '--- internal/bootstrap/bootstrap.go ---'
sed -n '1,220p' internal/bootstrap/bootstrap.go

echo ''
echo '--- Dockerfile ---'
sed -n '1,220p' Dockerfile

echo ''
echo '--- docker-compose.yml ---'
sed -n '1,220p' docker-compose.yml

echo ''
echo '--- .env.example ---'
sed -n '1,80p' .env.example

Repository: Jonathan0823/auth-go

Length of output: 7537


Reject the placeholder rate-limit HMAC key in production.

RATE_LIMIT_KEY is loaded as-is and only fails validation when empty, so change-me-rate-limit-key can pass startup validation if copied into production. Add a production validation rule that rejects the placeholder and requires a deployment-specific random value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example at line 22, Update the RATE_LIMIT_KEY configuration validation
to reject the default placeholder value change-me-rate-limit-key when running in
production, while continuing to require a non-empty deployment-specific random
value. Locate the existing startup validation for RATE_LIMIT_KEY and add this
production-only check without changing development behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant