axe/internal/ratelimit/db.go

89 lines
4.4 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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)
}
}
}
}