127 lines
4.1 KiB
Go
127 lines
4.1 KiB
Go
// Package ratelimit implementiert Core API-03: eine zentrale Rate-Limiting-
|
|
// Schicht fuer die gesamte Core-API mit GETEILTEM, EXTERNEM Zustand in
|
|
// Postgres — kein In-Process-Zaehler (siehe "Bekannte Fehler vermeiden" im
|
|
// Ticket: dasselbe Risiko wie bei IAM-07s In-Memory-Lockout). Fixed-Window-
|
|
// Algorithmus: einfach, korrekt unter nebenlaeufigem Zugriff (atomares
|
|
// UPSERT wie internal/usage.Store.Increment), und fuer ein API-Gateway
|
|
// ausreichend praezise — kein Sliding-Window-Overhead noetig.
|
|
package ratelimit
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// DefaultLimit/DefaultWindow gelten fuer jeden Schluessel (Tenant oder
|
|
// API-Token), fuer den keine eigene Konfiguration existiert
|
|
// (Akzeptanzkriterium 1: "konfigurierbar", nicht "verpflichtend konfiguriert").
|
|
const (
|
|
DefaultLimit = 100
|
|
DefaultWindow = time.Minute
|
|
)
|
|
|
|
// Config ist das je Schluessel konfigurierbare Limit.
|
|
type Config struct {
|
|
Limit int
|
|
Window time.Duration
|
|
}
|
|
|
|
// Store haelt Konfiguration UND Zaehlerstand in Postgres — beides ueber
|
|
// dieselbe Verbindung erreichbar, damit mehrere Dienstinstanzen denselben
|
|
// Zustand sehen (Akzeptanzkriterium 2).
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// SetLimit setzt die Konfiguration fuer EINEN Schluessel (z.B. einen
|
|
// Tenant-Slug oder eine API-Token-ID) — wirkt sich nicht auf andere
|
|
// Schluessel aus (Akzeptanzkriterium 1 / Pruefung 3).
|
|
func (s *Store) SetLimit(ctx context.Context, key string, limit int, window time.Duration) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO rate_limit_configs (key, limit_value, window_seconds)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (key) DO UPDATE SET limit_value = $2, window_seconds = $3
|
|
`, key, limit, int(window.Seconds()))
|
|
if err != nil {
|
|
return fmt.Errorf("rate-limit-konfiguration speichern: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) getConfig(ctx context.Context, key string) (Config, error) {
|
|
var limit, windowSeconds int
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT limit_value, window_seconds FROM rate_limit_configs WHERE key = $1
|
|
`, key).Scan(&limit, &windowSeconds)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return Config{Limit: DefaultLimit, Window: DefaultWindow}, nil
|
|
}
|
|
return Config{}, fmt.Errorf("rate-limit-konfiguration lesen: %w", err)
|
|
}
|
|
return Config{Limit: limit, Window: time.Duration(windowSeconds) * time.Second}, nil
|
|
}
|
|
|
|
// Result ist der Ausgang einer Allow-Pruefung (Akzeptanzkriterium 3).
|
|
type Result struct {
|
|
Allowed bool
|
|
Limit int
|
|
Remaining int
|
|
RetryAfter time.Duration
|
|
}
|
|
|
|
// Allow erhoeht den Zaehler fuer (key, aktuelles Zeitfenster) ATOMAR ueber
|
|
// ein einziges UPSERT (dasselbe Muster wie internal/usage.Store.Increment)
|
|
// und vergleicht das Ergebnis gegen die konfigurierte Grenze — kein
|
|
// Lesen-Erhoehen-Schreiben in Go, damit zwei Dienstinstanzen, die
|
|
// gleichzeitig gegen dieselbe Datenbank inkrementieren, sich niemals
|
|
// gegenseitig ueberschreiben (Akzeptanzkriterium 2 / Pruefung 1).
|
|
func (s *Store) Allow(ctx context.Context, key string) (Result, error) {
|
|
cfg, err := s.getConfig(ctx, key)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
windowSeconds := int64(cfg.Window.Seconds())
|
|
if windowSeconds <= 0 {
|
|
windowSeconds = int64(DefaultWindow.Seconds())
|
|
}
|
|
now := time.Now().UTC()
|
|
windowStart := time.Unix((now.Unix()/windowSeconds)*windowSeconds, 0).UTC()
|
|
windowEnd := windowStart.Add(time.Duration(windowSeconds) * time.Second)
|
|
|
|
var count int
|
|
err = s.pool.QueryRow(ctx, `
|
|
INSERT INTO rate_limit_counters (key, window_start, count)
|
|
VALUES ($1, $2, 1)
|
|
ON CONFLICT (key, window_start) DO UPDATE
|
|
SET count = rate_limit_counters.count + 1
|
|
RETURNING count
|
|
`, key, windowStart).Scan(&count)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("rate-limit-zaehler erhoehen: %w", err)
|
|
}
|
|
|
|
remaining := cfg.Limit - count
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
if count > cfg.Limit {
|
|
return Result{
|
|
Allowed: false,
|
|
Limit: cfg.Limit,
|
|
Remaining: 0,
|
|
RetryAfter: windowEnd.Sub(now),
|
|
}, nil
|
|
}
|
|
return Result{Allowed: true, Limit: cfg.Limit, Remaining: remaining}, nil
|
|
}
|