feat(api): лимиты частоты запросов (AGENT.md 8.6)
- httpx.RateLimiter: token bucket в памяти с уборкой неактивных ключей и подменяемым источником времени; - login/register — 5 запросов в минуту на IP, весь API — 120 в минуту на пользователя (по хэшу токена сессии) или на IP для анонимных запросов; - превышение отдаёт 429 с Retry-After и retry_after_ms в конверте ошибки; - тесты: наполнение и пополнение ведра, независимость ключей, 429 на ручке входа.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteErrorUsesEnvelope(t *testing.T) {
|
||||
@@ -177,3 +178,68 @@ func TestClientIPIgnoresForwardedForFromUntrustedPeer(t *testing.T) {
|
||||
t.Errorf("ClientIP() = %q, want the peer address", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterAllowsBurstThenBlocks(t *testing.T) {
|
||||
limiter := NewRateLimiter(5, 5)
|
||||
current := time.Unix(0, 0)
|
||||
limiter.SetClock(func() time.Time { return current })
|
||||
|
||||
for i := range 5 {
|
||||
if allowed, _ := limiter.Allow("1.2.3.4"); !allowed {
|
||||
t.Fatalf("request %d must be allowed", i+1)
|
||||
}
|
||||
}
|
||||
allowed, retryAfter := limiter.Allow("1.2.3.4")
|
||||
if allowed {
|
||||
t.Fatal("sixth request must be rate limited")
|
||||
}
|
||||
if retryAfter <= 0 {
|
||||
t.Fatal("retry_after must be positive")
|
||||
}
|
||||
|
||||
// Другой IP лимитом не затронут.
|
||||
if allowed, _ := limiter.Allow("5.6.7.8"); !allowed {
|
||||
t.Fatal("separate key must have its own bucket")
|
||||
}
|
||||
|
||||
// Через 12 секунд на скорости 5/мин накапливается один токен.
|
||||
current = current.Add(12 * time.Second)
|
||||
if allowed, _ := limiter.Allow("1.2.3.4"); !allowed {
|
||||
t.Fatal("bucket must refill over time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddlewareReturns429(t *testing.T) {
|
||||
limiter := NewRateLimiter(1, 1)
|
||||
handler := limiter.Middleware(func(*http.Request) string { return "client" })(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
handler.ServeHTTP(first, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first request = %d, want 200", first.Code)
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
handler.ServeHTTP(second, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second request = %d, want 429", second.Code)
|
||||
}
|
||||
if second.Header().Get("Retry-After") == "" {
|
||||
t.Error("Retry-After header is missing")
|
||||
}
|
||||
var payload struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
RetryAfterMS int64 `json:"retry_after_ms"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(second.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if payload.Error.Code != "rate_limited" || payload.Error.RetryAfterMS <= 0 {
|
||||
t.Fatalf("unexpected payload: %s", second.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user