package ratelimit import ( "context" "log/slog" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // Querier — всё, что лимитеру нужно от pgx: пул (pgxpool.Pool) или // транзакция (pgx.Tx). Интерфейс свой, а не из repository: пакет // лимитера не должен тянуть за собой весь слой репозиториев. type Querier interface { Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } // DBLimiter — лимитер на Postgres (таблица rate_limits, миграция 0009). // В отличие от in-memory Limiter, счётчик переживает рестарт процесса // и общий для нескольких инстансов API. Ключ хранится как «имя|IP»: // у каждого лимита своё имя, поэтому потолки разных эндпоинтов // не складываются в одну кучу. type DBLimiter struct { q Querier name string limit int window time.Duration } // NewDB создаёт лимитер name на limit запросов за window по одному ключу. // Имена — «login», «refresh», «register», «reset»: видно в таблице, // какой эндпоинт упёрся в потолок. func NewDB(q Querier, name string, limit int, window time.Duration) *DBLimiter { return &DBLimiter{q: q, name: name, limit: limit, window: window} } // upsertSQL засчитывает попытку одним round-trip'ом: окно кончилось — // счётчик и окно начинаются заново, нет — инкремент. Возвращается // счётчик ПОСЛЕ засчёта; гонки двух параллельных запросов не боятся: // ON CONFLICT сериализует обновление одной строки. // clock_timestamp(), а не now(): now() в Postgres — время начала // транзакции, внутри длинной транзакции окно не сдвинулось бы. const upsertSQL = ` INSERT INTO rate_limits (key, window_start, count) VALUES ($1, clock_timestamp(), 1) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN rate_limits.window_start + make_interval(secs => $2) > clock_timestamp() THEN rate_limits.count + 1 ELSE 1 END, window_start = CASE WHEN rate_limits.window_start + make_interval(secs => $2) > clock_timestamp() THEN rate_limits.window_start ELSE clock_timestamp() END RETURNING count` // Allow засчитывает попытку и говорит, разрешена ли она. // Ошибка БД не блокирует запрос (fail-open): лимитер — не то место, // из-за которого падает вход; доступ к базе и так нужен самому логину, // а ошибку видно в логе. func (l *DBLimiter) Allow(key string) bool { var count int err := l.q.QueryRow(context.Background(), upsertSQL, l.name+"|"+key, float64(l.window.Seconds())).Scan(&count) if err != nil { slog.Warn("rate limit: db check failed, request allowed", "limiter", l.name, "err", err) return true } return count <= l.limit } // RunCleanup периодически выбрасывает протухшие строки: самое длинное // окно — «3 письма в сутки» на forgotten-пароли (24 часа), поэтому // строки старше 25 часов (час запаса) не понадобятся никогда. Короткие // минутные окна живут дольше нужного — для шкалы rate_limits это // копейки (строка на IP/ящик), а чистка одна на все лимиты. // Останавливается по отмене ctx (graceful shutdown в cmd/api). func RunCleanup(ctx context.Context, q Querier, every time.Duration) { t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if _, err := q.Exec(ctx, `DELETE FROM rate_limits WHERE window_start < now() - interval '25 hours'`); err != nil { slog.Warn("rate limit: cleanup failed", "err", err) } } } }