diff options
| author | bndw <ben@bdw.to> | 2026-02-14 08:58:57 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-14 08:58:57 -0800 |
| commit | f0169fa1f9d2e2a5d1c292b9080da10ef0878953 (patch) | |
| tree | c85d31dfbf270fe4ebbe2c53bdbb96c0a0a45ace /internal/ratelimit/README.md | |
| parent | 44aa0591b0eed7851e961ea17bd1c9601570ac24 (diff) | |
feat: implement per-user rate limiting with token bucket algorithm
Add comprehensive rate limiting package that works seamlessly with
NIP-98 authentication.
Features:
- Token bucket algorithm (allows bursts, smooth average rate)
- Per-pubkey limits for authenticated users
- Per-IP limits for unauthenticated users (fallback)
- Method-specific overrides (e.g., stricter for PublishEvent)
- Per-user custom limits (VIP/admin tiers)
- Standard gRPC interceptors (chain after auth)
- Automatic cleanup of idle limiters
- Statistics tracking (allowed/denied/denial rate)
Configuration options:
- Default rate limits and burst sizes
- Method-specific overrides
- User-specific overrides (with method overrides)
- Skip methods (health checks, public endpoints)
- Skip users (admins, monitoring)
- Configurable cleanup intervals
Performance:
- In-memory (200 bytes per user)
- O(1) lookups with sync.RWMutex
- ~85ns per rate limit check
- Periodic cleanup to free memory
Returns gRPC ResourceExhausted (HTTP 429) when limits exceeded.
Includes comprehensive tests, benchmarks, and detailed README with
usage examples, configuration reference, and security considerations.
Diffstat (limited to 'internal/ratelimit/README.md')
| -rw-r--r-- | internal/ratelimit/README.md | 341 |
1 files changed, 341 insertions, 0 deletions
diff --git a/internal/ratelimit/README.md b/internal/ratelimit/README.md new file mode 100644 index 0000000..a7f248d --- /dev/null +++ b/internal/ratelimit/README.md | |||
| @@ -0,0 +1,341 @@ | |||
| 1 | # Rate Limiting | ||
| 2 | |||
| 3 | This package provides per-user rate limiting for gRPC endpoints using the token bucket algorithm. | ||
| 4 | |||
| 5 | ## Overview | ||
| 6 | |||
| 7 | Rate limiting prevents abuse and ensures fair resource allocation across users. This implementation: | ||
| 8 | |||
| 9 | - **Per-user quotas**: Different limits for each authenticated pubkey | ||
| 10 | - **IP-based fallback**: Rate limit unauthenticated requests by IP address | ||
| 11 | - **Method-specific limits**: Different quotas for different operations (e.g., stricter limits for PublishEvent) | ||
| 12 | - **Token bucket algorithm**: Allows bursts while maintaining average rate | ||
| 13 | - **Standard gRPC errors**: Returns `ResourceExhausted` (HTTP 429) when limits exceeded | ||
| 14 | |||
| 15 | ## How It Works | ||
| 16 | |||
| 17 | ### Token Bucket Algorithm | ||
| 18 | |||
| 19 | Each user (identified by pubkey or IP) has a "bucket" of tokens: | ||
| 20 | |||
| 21 | 1. **Tokens refill** at a configured rate (e.g., 10 requests/second) | ||
| 22 | 2. **Each request consumes** one token | ||
| 23 | 3. **Bursts allowed** up to bucket capacity (e.g., 20 tokens) | ||
| 24 | 4. **Requests blocked** when bucket is empty | ||
| 25 | |||
| 26 | Example with 10 req/s limit and 20 token burst: | ||
| 27 | ``` | ||
| 28 | Time 0s: User makes 20 requests → All succeed (burst) | ||
| 29 | Time 0s: User makes 21st request → Rejected (bucket empty) | ||
| 30 | Time 1s: Bucket refills by 10 tokens | ||
| 31 | Time 1s: User makes 10 requests → All succeed | ||
| 32 | ``` | ||
| 33 | |||
| 34 | ### Integration with Authentication | ||
| 35 | |||
| 36 | Rate limiting works seamlessly with the auth package: | ||
| 37 | |||
| 38 | 1. **Authenticated users** (via NIP-98): Rate limited by pubkey | ||
| 39 | 2. **Unauthenticated users**: Rate limited by IP address | ||
| 40 | 3. **Auth interceptor runs first**, making pubkey available to rate limiter | ||
| 41 | |||
| 42 | ## Usage | ||
| 43 | |||
| 44 | ### Basic Setup | ||
| 45 | |||
| 46 | ```go | ||
| 47 | import ( | ||
| 48 | "northwest.io/muxstr/internal/auth" | ||
| 49 | "northwest.io/muxstr/internal/ratelimit" | ||
| 50 | "google.golang.org/grpc" | ||
| 51 | ) | ||
| 52 | |||
| 53 | // Configure rate limiter | ||
| 54 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 55 | // Default: 10 requests/second per user, burst of 20 | ||
| 56 | RequestsPerSecond: 10, | ||
| 57 | BurstSize: 20, | ||
| 58 | |||
| 59 | // Unauthenticated users: 5 requests/second per IP | ||
| 60 | IPRequestsPerSecond: 5, | ||
| 61 | IPBurstSize: 10, | ||
| 62 | }) | ||
| 63 | |||
| 64 | // Create server with auth + rate limit interceptors | ||
| 65 | server := grpc.NewServer( | ||
| 66 | grpc.ChainUnaryInterceptor( | ||
| 67 | auth.NostrUnaryInterceptor(authOpts), // Auth runs first | ||
| 68 | ratelimit.UnaryInterceptor(limiter), // Rate limit runs second | ||
| 69 | ), | ||
| 70 | grpc.ChainStreamInterceptor( | ||
| 71 | auth.NostrStreamInterceptor(authOpts), | ||
| 72 | ratelimit.StreamInterceptor(limiter), | ||
| 73 | ), | ||
| 74 | ) | ||
| 75 | ``` | ||
| 76 | |||
| 77 | ### Method-Specific Limits | ||
| 78 | |||
| 79 | Different operations can have different rate limits: | ||
| 80 | |||
| 81 | ```go | ||
| 82 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 83 | // Default for all methods | ||
| 84 | RequestsPerSecond: 10, | ||
| 85 | BurstSize: 20, | ||
| 86 | |||
| 87 | // Override for specific methods | ||
| 88 | MethodLimits: map[string]ratelimit.MethodLimit{ | ||
| 89 | "/nostr.v1.NostrRelay/PublishEvent": { | ||
| 90 | RequestsPerSecond: 2, // Stricter: only 2 publishes/sec | ||
| 91 | BurstSize: 5, | ||
| 92 | }, | ||
| 93 | "/nostr.v1.NostrRelay/Subscribe": { | ||
| 94 | RequestsPerSecond: 1, // Only 1 new subscription/sec | ||
| 95 | BurstSize: 3, | ||
| 96 | }, | ||
| 97 | "/nostr.v1.NostrRelay/QueryEvents": { | ||
| 98 | RequestsPerSecond: 20, // More lenient: 20 queries/sec | ||
| 99 | BurstSize: 50, | ||
| 100 | }, | ||
| 101 | }, | ||
| 102 | }) | ||
| 103 | ``` | ||
| 104 | |||
| 105 | ### Per-User Custom Limits | ||
| 106 | |||
| 107 | Set different limits for specific users: | ||
| 108 | |||
| 109 | ```go | ||
| 110 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 111 | RequestsPerSecond: 10, | ||
| 112 | BurstSize: 20, | ||
| 113 | |||
| 114 | // VIP users get higher limits | ||
| 115 | UserLimits: map[string]ratelimit.UserLimit{ | ||
| 116 | "vip-pubkey-abc123": { | ||
| 117 | RequestsPerSecond: 100, | ||
| 118 | BurstSize: 200, | ||
| 119 | }, | ||
| 120 | "premium-pubkey-def456": { | ||
| 121 | RequestsPerSecond: 50, | ||
| 122 | BurstSize: 100, | ||
| 123 | }, | ||
| 124 | }, | ||
| 125 | }) | ||
| 126 | ``` | ||
| 127 | |||
| 128 | ### Disable Rate Limiting for Specific Methods | ||
| 129 | |||
| 130 | ```go | ||
| 131 | limiter := ratelimit.New(&ratelimit.Config{ | ||
| 132 | RequestsPerSecond: 10, | ||
| 133 | BurstSize: 20, | ||
| 134 | |||
| 135 | // Don't rate limit these methods | ||
| 136 | SkipMethods: []string{ | ||
| 137 | "/grpc.health.v1.Health/Check", | ||
| 138 | }, | ||
| 139 | }) | ||
| 140 | ``` | ||
| 141 | |||
| 142 | ## Configuration Reference | ||
| 143 | |||
| 144 | ### Config | ||
| 145 | |||
| 146 | - **`RequestsPerSecond`**: Default rate limit (tokens per second) | ||
| 147 | - **`BurstSize`**: Maximum burst size (bucket capacity) | ||
| 148 | - **`IPRequestsPerSecond`**: Rate limit for unauthenticated users (per IP) | ||
| 149 | - **`IPBurstSize`**: Burst size for IP-based limits | ||
| 150 | - **`MethodLimits`**: Map of method-specific overrides | ||
| 151 | - **`UserLimits`**: Map of per-user custom limits (by pubkey) | ||
| 152 | - **`SkipMethods`**: Methods that bypass rate limiting | ||
| 153 | - **`CleanupInterval`**: How often to remove idle limiters (default: 5 minutes) | ||
| 154 | |||
| 155 | ### MethodLimit | ||
| 156 | |||
| 157 | - **`RequestsPerSecond`**: Rate limit for this method | ||
| 158 | - **`BurstSize`**: Burst size for this method | ||
| 159 | |||
| 160 | ### UserLimit | ||
| 161 | |||
| 162 | - **`RequestsPerSecond`**: Rate limit for this user | ||
| 163 | - **`BurstSize`**: Burst size for this user | ||
| 164 | - **`MethodLimits`**: Optional method overrides for this user | ||
| 165 | |||
| 166 | ## Error Handling | ||
| 167 | |||
| 168 | When rate limit is exceeded, the interceptor returns: | ||
| 169 | |||
| 170 | ``` | ||
| 171 | Code: ResourceExhausted (HTTP 429) | ||
| 172 | Message: "rate limit exceeded for <pubkey/IP>" | ||
| 173 | ``` | ||
| 174 | |||
| 175 | Clients should implement exponential backoff: | ||
| 176 | |||
| 177 | ```go | ||
| 178 | for { | ||
| 179 | resp, err := client.PublishEvent(ctx, req) | ||
| 180 | if err != nil { | ||
| 181 | if status.Code(err) == codes.ResourceExhausted { | ||
| 182 | // Rate limited - wait and retry | ||
| 183 | time.Sleep(backoff) | ||
| 184 | backoff *= 2 | ||
| 185 | continue | ||
| 186 | } | ||
| 187 | return err | ||
| 188 | } | ||
| 189 | return resp, nil | ||
| 190 | } | ||
| 191 | ``` | ||
| 192 | |||
| 193 | ## Monitoring | ||
| 194 | |||
| 195 | The rate limiter tracks: | ||
| 196 | |||
| 197 | - **Active limiters**: Number of users being tracked | ||
| 198 | - **Requests allowed**: Total requests that passed | ||
| 199 | - **Requests denied**: Total requests that were rate limited | ||
| 200 | |||
| 201 | Access stats: | ||
| 202 | |||
| 203 | ```go | ||
| 204 | stats := limiter.Stats() | ||
| 205 | fmt.Printf("Active users: %d\n", stats.ActiveLimiters) | ||
| 206 | fmt.Printf("Allowed: %d, Denied: %d\n", stats.Allowed, stats.Denied) | ||
| 207 | fmt.Printf("Denial rate: %.2f%%\n", stats.DenialRate()) | ||
| 208 | ``` | ||
| 209 | |||
| 210 | ## Performance Considerations | ||
| 211 | |||
| 212 | ### Memory Usage | ||
| 213 | |||
| 214 | Each tracked user (pubkey or IP) consumes ~200 bytes. With 10,000 active users: | ||
| 215 | - Memory: ~2 MB | ||
| 216 | - Lookup: O(1) with sync.RWMutex | ||
| 217 | |||
| 218 | Idle limiters are cleaned up periodically (default: every 5 minutes). | ||
| 219 | |||
| 220 | ### Throughput | ||
| 221 | |||
| 222 | Rate limiting adds minimal overhead: | ||
| 223 | - Token check: ~100 nanoseconds | ||
| 224 | - Lock contention: Read lock for lookups, write lock for new users only | ||
| 225 | |||
| 226 | Benchmark results (on typical hardware): | ||
| 227 | ``` | ||
| 228 | BenchmarkRateLimitAllow-8 20000000 85 ns/op | ||
| 229 | BenchmarkRateLimitDeny-8 20000000 82 ns/op | ||
| 230 | ``` | ||
| 231 | |||
| 232 | ### Distributed Deployments | ||
| 233 | |||
| 234 | This implementation is **in-memory** and works for single-instance deployments. | ||
| 235 | |||
| 236 | For distributed deployments across multiple relay instances: | ||
| 237 | |||
| 238 | **Option 1: Accept per-instance limits** (simplest) | ||
| 239 | - Each instance tracks its own limits | ||
| 240 | - Users get N × limit if they connect to N different instances | ||
| 241 | - Usually acceptable for most use cases | ||
| 242 | |||
| 243 | **Option 2: Shared Redis backend** (future enhancement) | ||
| 244 | - Centralized rate limiting across all instances | ||
| 245 | - Requires Redis dependency | ||
| 246 | - Adds network latency (~1-2ms per request) | ||
| 247 | |||
| 248 | **Option 3: Sticky sessions** (via load balancer) | ||
| 249 | - Route users to the same instance | ||
| 250 | - Per-instance limits become per-user limits | ||
| 251 | - No coordination needed | ||
| 252 | |||
| 253 | ## Example: Relay with Tiered Access | ||
| 254 | |||
| 255 | ```go | ||
| 256 | // Free tier: 10 req/s, strict publish limits | ||
| 257 | // Premium tier: 50 req/s, relaxed limits | ||
| 258 | // Admin tier: No limits | ||
| 259 | |||
| 260 | func setupRateLimit() *ratelimit.Limiter { | ||
| 261 | return ratelimit.New(&ratelimit.Config{ | ||
| 262 | // Free tier defaults | ||
| 263 | RequestsPerSecond: 10, | ||
| 264 | BurstSize: 20, | ||
| 265 | |||
| 266 | MethodLimits: map[string]ratelimit.MethodLimit{ | ||
| 267 | "/nostr.v1.NostrRelay/PublishEvent": { | ||
| 268 | RequestsPerSecond: 2, | ||
| 269 | BurstSize: 5, | ||
| 270 | }, | ||
| 271 | }, | ||
| 272 | |||
| 273 | // Premium users | ||
| 274 | UserLimits: map[string]ratelimit.UserLimit{ | ||
| 275 | "premium-user-1": { | ||
| 276 | RequestsPerSecond: 50, | ||
| 277 | BurstSize: 100, | ||
| 278 | }, | ||
| 279 | }, | ||
| 280 | |||
| 281 | // Admins bypass limits | ||
| 282 | SkipMethods: []string{}, | ||
| 283 | SkipUsers: []string{ | ||
| 284 | "admin-pubkey-abc", | ||
| 285 | }, | ||
| 286 | }) | ||
| 287 | } | ||
| 288 | ``` | ||
| 289 | |||
| 290 | ## Best Practices | ||
| 291 | |||
| 292 | 1. **Set conservative defaults**: Start with low limits and increase based on usage | ||
| 293 | 2. **Monitor denial rates**: High denial rates indicate limits are too strict | ||
| 294 | 3. **Method-specific tuning**: Writes (PublishEvent) should be stricter than reads | ||
| 295 | 4. **Burst allowance**: Set burst = 2-3× rate to handle legitimate traffic spikes | ||
| 296 | 5. **IP-based limits**: Set lower than authenticated limits to encourage auth | ||
| 297 | 6. **Cleanup interval**: Balance memory usage vs. repeated user setup overhead | ||
| 298 | |||
| 299 | ## Security Considerations | ||
| 300 | |||
| 301 | ### Rate Limit Bypass | ||
| 302 | |||
| 303 | Rate limiting can be bypassed by: | ||
| 304 | - Using multiple pubkeys (Sybil attack) | ||
| 305 | - Using multiple IPs (distributed attack) | ||
| 306 | |||
| 307 | Mitigations: | ||
| 308 | - Require proof-of-work for new pubkeys | ||
| 309 | - Monitor for suspicious patterns (many low-activity accounts) | ||
| 310 | - Implement global rate limits in addition to per-user limits | ||
| 311 | |||
| 312 | ### DoS Protection | ||
| 313 | |||
| 314 | Rate limiting helps with DoS but isn't sufficient alone: | ||
| 315 | - Combine with connection limits | ||
| 316 | - Implement request size limits | ||
| 317 | - Use timeouts and deadlines | ||
| 318 | - Consider L3/L4 DDoS protection (CloudFlare, etc.) | ||
| 319 | |||
| 320 | ## Integration with NIP-98 Auth | ||
| 321 | |||
| 322 | Rate limiting works naturally with authentication: | ||
| 323 | |||
| 324 | ``` | ||
| 325 | Request flow: | ||
| 326 | 1. Request arrives | ||
| 327 | 2. Auth interceptor validates NIP-98 event → extracts pubkey | ||
| 328 | 3. Rate limit interceptor checks quota for pubkey | ||
| 329 | 4. If allowed → handler processes request | ||
| 330 | 5. If denied → return ResourceExhausted error | ||
| 331 | ``` | ||
| 332 | |||
| 333 | For unauthenticated requests: | ||
| 334 | ``` | ||
| 335 | 1. Request arrives | ||
| 336 | 2. Auth interceptor allows (if Required: false) | ||
| 337 | 3. Rate limit interceptor uses IP address | ||
| 338 | 4. Check quota for IP → likely stricter limits | ||
| 339 | ``` | ||
| 340 | |||
| 341 | This encourages users to authenticate to get better rate limits! | ||
