// Package ratelimit enforces per-key and per-end-user request ceilings. // // Redis is authoritative for the live window; api.usage_daily holds completed // days. A Redis flush therefore loses at most the window in progress, which for // abuse detection is an acceptable trade. package ratelimit import ( "context" "time" ) // Result is what a limiter says about one request. type Result struct { Allowed bool Limit int Remaining int RetryAfter time.Duration } // Limiter is deliberately narrow so the middleware can be tested without Redis, // and so a self-hoster running a single instance could drop in an in-process // implementation instead of standing up Redis at all. type Limiter interface { // Allow records one request against key and reports whether it may // proceed. Implementations must be atomic: a check-then-increment race // under load is exactly when the limit matters most. Allow(ctx context.Context, key string, limit int, window time.Duration) (Result, error) } // Quota is the ceiling for one tier, from api.keys.quota_tier. type Quota struct { // Requests allowed per window for the key as a whole. KeyLimit int // Requests allowed per window for a single end user within that key. One // Discord user should not be able to consume a whole self-hosted // instance's allowance, and a key whose traffic is 90% one user is the // enumeration signal worth alerting on. UserLimit int Window time.Duration } // Tiers maps api.keys.quota_tier to its ceiling. Values are a starting point, // not a considered policy: watch real usage before hardening them. var Tiers = map[string]Quota{ "default": {KeyLimit: 600, UserLimit: 120, Window: time.Minute}, "verified": {KeyLimit: 3000, UserLimit: 600, Window: time.Minute}, "trusted": {KeyLimit: 12000, UserLimit: 2400, Window: time.Minute}, "internal": {KeyLimit: 0, UserLimit: 0, Window: time.Minute}, // 0 = unlimited } // QuotaFor falls back to the tightest tier for an unknown name, so a typo in // the database cannot accidentally grant unlimited access. func QuotaFor(tier string) Quota { if q, ok := Tiers[tier]; ok { return q } return Tiers["default"] }