website/pkg/ratelimit/ratelimit.go

112 lines
3.0 KiB
Go
Raw Permalink Normal View History

package ratelimit
import (
"fmt"
"time"
2022-08-26 04:21:46 +00:00
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/utility"
)
// Limiter implements a Redis-backed rate limit for logins or otherwise.
type Limiter struct {
Namespace string // kind of rate limiter ("login")
ID interface{} // unique ID of the resource being pinged (str or ints)
Limit int // how many pings within the window period
Window time.Duration // the window period/expiration of Redis key
CooldownAt int // how many pings before the cooldown is enforced
Cooldown time.Duration // time to wait between fails
}
// Redis object behind the rate limiter.
type Data struct {
Pings int
NotBefore time.Time
}
// Ping the rate limiter.
//
// The returned error can be one of the following types:
//
// - ErrLockedOut if the user is being cooled down - the caller should not attempt to process their request.
// - ErrDeferred if the rate limiter is being invokved - the caller can process their request, and if failed, show this error.
//
// Other error types signify internal errors, e.g. inability to set a Redis key.
func (l *Limiter) Ping() error {
var (
key = l.Key()
now = time.Now()
)
// Get stored data from Redis if any.
var data Data
redis.Get(key, &data)
// Are we cooling down?
if now.Before(data.NotBefore) {
return NewLockedOutError(
"You are doing that too often. Please wait %s before trying again.",
utility.FormatDurationCoarse(data.NotBefore.Sub(now)),
)
}
// Increment the ping count.
data.Pings++
// Have we hit the wall?
if data.Pings >= l.Limit {
return NewLockedOutError(
"You have hit the rate limit; please wait the full %s before trying again.",
utility.FormatDurationCoarse(l.Window),
)
}
// Are we throttled?
2022-09-27 02:12:24 +00:00
if l.CooldownAt > 0 && data.Pings > l.CooldownAt {
data.NotBefore = now.Add(l.Cooldown)
if err := redis.Set(key, data, l.Window); err != nil {
return fmt.Errorf("Couldn't set Redis key for rate limiter: %s", err)
}
return NewDeferredError(
"Please wait %s before trying again. You have %d more attempt(s) remaining before you will be locked "+
"out for %s.",
utility.FormatDurationCoarse(l.Cooldown),
l.Limit-data.Pings,
utility.FormatDurationCoarse(l.Window),
)
}
// Save their ping count to Redis.
if err := redis.Set(key, data, l.Window); err != nil {
return fmt.Errorf("Couldn't set Redis key for rate limiter: %s", err)
}
return nil
}
// Clear the rate limiter, cleaning up the Redis key (e.g., after successful login).
func (l *Limiter) Clear() error {
return redis.Delete(l.Key())
}
// Key formats the Redis key.
func (l *Limiter) Key() string {
var str string
switch t := l.ID.(type) {
case int:
str = fmt.Sprintf("%d", t)
case uint64:
str = fmt.Sprintf("%d", t)
case int64:
str = fmt.Sprintf("%d", t)
case uint32:
str = fmt.Sprintf("%d", t)
case int32:
str = fmt.Sprintf("%d", t)
default:
str = fmt.Sprintf("%s", t)
}
return fmt.Sprintf(config.RateLimitRedisKey, l.Namespace, str)
}