axe/internal/ratelimit/db.go
2026-10-18 22:07:25 +03:00

89 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}
}