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