feat(relay): per-IP token-bucket rate-limit type with eviction (#50)#52
Conversation
Introduces IPRateLimiter — the pre-upgrade attempt-throttling primitive named in docs/threat-model.md § DoS resistance. Ships the type only; IP-extraction and upgrade-handler wiring are sibling tickets. Per-IP token bucket guarded by one sync.Mutex; integer-token refill math (no float drift); single owned eviction goroutine started in NewIPRateLimiter, stopped via idempotent Close. Two timestamps per bucket decouple two concerns: refillLast anchors integer-token refill math; pollLast anchors the last Allow call and updates on every call including denies. This keeps eviction from dropping a bucket an attacker is actively hammering (which would grant a free fresh burst on the next request) while still reclaiming genuinely idle buckets under address-space scanning. Regression- guarded by TestIPRateLimiter_EvictionPreservesActiveBucket. No new dependencies; stdlib only. No changes to main — the wiring ticket is what registers defer limiter.Close() at the composition root. Tests cover burst-then-deny, refill-after-advance, fractional-refill preservation, per-IP isolation, eviction of idle + preservation of active buckets, goroutine end-to-end, Close idempotency, Close stops the goroutine, and race-freedom (32 goroutines × 200 ops vs. a 1ms-sweep goroutine). Stress: go test -race -count=20 -run TestIPRateLimiter_RaceFreedom ./internal/relay Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Review: #50Decision: PASS Findings
Security review (label:
|
PROJECT-MEMORY § Patterns established: type-owned background goroutine (ticker + done + WaitGroup with wg.Wait outside closeOnce); two-timestamp decoupling when one field would conflate semantic purposes; injectable now func() time.Time as unexported field. lessons.md: integer token-bucket refill anchor advances by token-sized chunks (not to now) to preserve fractional time; wg.Wait outside closeOnce.Do so concurrent Close callers also block; range+delete on a map is spec-safe (no snapshot needed). codebase/50.md was authored by the implementer; INDEX.md unchanged (no new feature/decision/architecture doc — limiter is unwired until #47). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
What
Introduces
IPRateLimiter(internal/relay/ratelimit.go) — the pre-upgrade attempt-throttling primitive named indocs/threat-model.md§ DoS resistance. Per-IP token bucket guarded by onesync.Mutex; integer-token refill (no float drift); single owned eviction goroutine started inNewIPRateLimiter, stopped via idempotentClose. No new dependencies (stdlib only).This is the primitive only. IP-extraction and upgrade-handler wiring are sibling tickets —
cmd/pyrycode-relay/main.gois intentionally untouched.Issue
Closes #50. Split from #46; sibling of the IP-extraction helper ticket and of #47 (wiring).
Design highlight — two-field eviction invariant
Each bucket carries two timestamps:
refillLast— last credited refill, advances in integer-token chunks (preserves fractional elapsed time).pollLast— lastAllowcall, advances tonowon every call including denies.Eviction predicate is
now - pollLast >= burst * refillEvery. The invariant "eviction never drops a bucket below capacity" follows frompollLastbeing load-bearing on every call: an actively-hammered (denied) bucket stays resident, so an attacker cannot recycle a fresh burst by waiting out an eviction cycle. Single-timestamp designs structurally cannot satisfy both this and the fractional-refill-preservation property.Testing
10 tests in
internal/relay/ratelimit_test.go:BurstThenDeny,RefillAfterAdvance,FractionalRefillPreserved,PerIPIsolation— refill math + isolation.EvictionDropsIdleBucket— sweep drops a bucket past threshold.EvictionPreservesActiveBucket— regression guard for the two-field invariant.EvictionGoroutineRunsAndDrops— only test using real-clock sleep; end-to-end goroutine path.CloseIsIdempotent,CloseStopsEvictionGoroutine— close lifecycle.RaceFreedom— 32 goroutines × 200 ops against a 1ms-sweep goroutine; run withgo test -race -count=20 -run TestIPRateLimiter_RaceFreedom ./internal/relay.Fake clock is local to the test file; constructor helper registers
t.Cleanup(l.Close). Most tests construct withevictionInterval=time.Hour(goroutine effectively never fires) and drivesweep()directly under a fake clock — fully deterministic, no sleep.All green under
go test -race ./...andgo vet ./...;go build ./cmd/pyrycode-relaysucceeds.Architecture compliance
Follows
docs/specs/architecture/50-ip-rate-limiter.mddirectly: file layout, public API, refill math, eviction rule, goroutine lifecycle, and test list match the spec. The spec's security review (verdict: PASS, no findings) shaped the two-field design + the documented-not-enforced parameter contracts.Mirrors patterns from
internal/relay/registry.go(passive in-memory store under one lock; race-test shape lifted fromTestRegistry_RaceFreedom). No update todocs/threat-model.mdordocs/PROJECT-MEMORY.md§ What's built — both are #47's job once the primitive is actually wired into traffic.Test plan
go test -race ./...greengo vet ./...cleango build ./cmd/pyrycode-relaysucceedsgo test -race -count=20 -run TestIPRateLimiter_RaceFreedom ./internal/relaygreen (20 stress iterations)🤖 Generated with Claude Code