Auth

Rate Limiting та Throttling в ASP.NET Core

Глибоке занурення в Rate Limiting: навіщо він потрібен, як працюють чотири алгоритми, per-user та per-IP стратегії, Redis для розподілених систем та правильне проєктування відміни запитів.

Rate Limiting та Throttling в ASP.NET Core

Чому API без обмежень — це не просто «незручність», а реальна загроза

Уявіть, що ви запустили публічний API для пошуку товарів у вашому маркетплейсі. В перший день все чудово: сотні запитів на хвилину, сервер справляється. А на другий день конкурент запускає скрипт, що робить 50 000 запитів на хвилину, індексуючи весь ваш каталог. Сервер починає відповідати все повільніше, бази перегріваються, реальні користувачі бачать «503 Service Unavailable». Ваш бізнес стоїть.

Або інший сценарій: хтось виявив форму реєстрації і запускає brute-force атаку на паролі — 10 000 спроб за секунду. Без обмежень у вас немає жодного шансу захиститися.

Rate limiting — це не просто «хороша практика». Це базовий захисний механізм будь-якого публічного або напівпублічного API. У .NET 7 він нарешті став першокласним вбудованим інструментом ASP.NET Core.

Що таке Rate Limiting vs Throttling?

Ці терміни часто плутають, але між ними є принципова різниця в підході до надлишкових запитів:

Rate Limiting (обмеження швидкості) — відхиляє запити, що перевищують встановлений ліміт. Клієнт отримує 429 Too Many Requests і має чекати.

Throttling (дроселювання) — уповільнює обробку запитів замість відхилення. Запити поміщаються у чергу та виконуються поступово.

У ASP.NET Core RateLimiter підтримує обидва підходи через параметр QueueLimit. Якщо QueueLimit = 0 — це чистий rate limiting (відхиляємо одразу). Якщо QueueLimit > 0 — це throttling (ставимо в чергу до QueueLimit запитів).

Що захищає Rate Limiting?

Перед написанням коду важливо розуміти повний спектр загроз, від яких захищає rate limiting:

ЗагрозаЯк відбуваєтьсяЩо захищає Rate Limiting
DoS / DDoSТисячі запитів з одного IP або ботнетуPer-IP ліміт знизив навантаження задовго до вичерпання ресурсів
Brute Force10 000 спроб пароля на хвилинуLogin endpoint: max 5 спроб/хв per IP
Web ScrapingАвтоматичне завантаження всього каталогуЗагальний per-IP ліміт робить scraping дорогим
API AbuseЗловживання безкоштовною квотоюPer-user квота: 100 req/год для free, 5000 для pro
Resource ExhaustionДорогі ML/DB операції спамлятьсяConcurrency limiter: max 5 одночасних важких задач
Credential StuffingПеревірка вкрадених паролівAccount lockout + rate limit на /auth/login

Це не теоретичні сценарії. Кожен з них трапляється з реальними API щодня.


1. Чотири алгоритми: в чому різниця?

ASP.NET Core надає чотири алгоритми rate limiting. Кожен оптимізований для свого сценарію, і вибір між ними — не косметичний, а принциповий архітектурний вибір.

Fixed Window: простий, але з «крайовим ефектом»

Fixed Window (фіксоване вікно) — найпростіший алгоритм. Він ділить час на рівні вікна (наприклад, хвилини) і рахує кількість запитів у кожному вікні. Якщо ліміт досягнуто — всі запити до кінця вікна відхиляються.

Ліміт: 10 req / хвилина

Хвилина 1: ████████░░  8 запитів — OK
Хвилина 2: ██████████  10 запитів — ліміт досягнуто
           ├ :45 — останній дозволений
           └ :46,:47,:48,:49,:50,:51... → 429

Хвилина 3: ░░░░░░░░░░  Лічильник скидається, новий дозволений

Проблема крайового ефекту. Уявіть ліміт 100 req/хв. О 0:59 хтось надсилає 100 запитів (остання секунда першої хвилини). О 1:00 лічильник скидається — і об 1:01 ще 100 запитів. Фактично за 2 секунди клієнт зробив 200 запитів — вдвічі більше ліміту. Fixed Window не захищає від такого «burst attack на межі вікна».

Program.cs — Fixed Window Limiter
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter(
        policyName: "fixed",
        configureOptions: limiter =>
        {
            limiter.PermitLimit          = 10;
            limiter.Window               = TimeSpan.FromSeconds(30);
            limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            limiter.QueueLimit           = 2;
        });
});

Анатомія коду:

  • PermitLimit = 10 — максимум 10 запитів за одне вікно.
  • Window = TimeSpan.FromSeconds(30) — тривалість одного вікна. Після закінчення лічильник скидається до нуля.
  • QueueLimit = 2 — ці два запити не отримають 429, а будуть чекати до початку наступного вікна. Решта — негайно 429. Без черги — QueueLimit = 0.
  • QueueProcessingOrder.OldestFirst — справедлива черга FIFO: хто прийшов перший — той перший виконається при звільненні слоту.

Коли використовувати Fixed Window:

  • Прості ендпоінти без критичних вимог до рівномірності трафіку.
  • Адміністративні задачі (наприклад, генерація звітів раз на годину).
  • Коли простота реалізації важливіша за точність.

Sliding Window: справедливий та точний

Sliding Window (плинне вікно) вирішує проблему крайового ефекту. Замість фіксованих меж, вікно «рухається» разом з часом. Поцей момент клієнт може зробити N запитів за будь-які останні T секунд.

Ліміт: 10 req / 60 секунд (Sliding)

Час  10 20 30 40 50 60 70 80
     ██ ██ ██ ██ ██ ██ __ __  ← 6 запитів за час 10-60
                    ↑
                    Нові запити — дивимось: скільки запитів за 10-70?
                    6 запитів → ще 4 слоти доступні ✓
Program.cs — Sliding Window Limiter
builder.Services.AddRateLimiter(options =>
{
    options.AddSlidingWindowLimiter(
        policyName: "sliding",
        configureOptions: limiter =>
        {
            limiter.PermitLimit         = 100;
            limiter.Window              = TimeSpan.FromMinutes(1);
            limiter.SegmentsPerWindow   = 6;
            limiter.QueueLimit          = 0;
        });
});

Анатомія коду:

  • SegmentsPerWindow = 6 — вікно ділиться на 6 підсегментів по 10 секунд. Алгоритм слайдить по цих сегментах, а не по кожній мілісекунді. Більше сегментів = точніше відстеження, але більше пам'яті.

Коли використовувати Sliding Window:

  • Публічний API з підписками (100 req/хв для free).
  • Будь-де, де важлива рівномірність без «burst effects».
  • Більшість сценаріїв per-user rate limiting.

Token Bucket: burst-friendly та гнучкий

Token Bucket (відро токенів) — найгнучкіший алгоритм. Уявіть відро, в яке крапає вода з константною швидкістю. Кожен запит «випиває» один токен. Якщо токени є — запит проходить. Якщо відро порожнє — 429.

Але ключова властивість: якщо клієнт деякий час не робив запитів — токени накопичуються до максимальної місткості відра. Тобто він може зробити «спалах» запитів, використавши накопичений ресурс.

TokenLimit = 20 (місткість), TokensPerPeriod = 5/сек

Без запитів 3 секунди:  відро накопичило 15 токенів
Раптовий burst:         15 запитів одразу → все пройшло ✓
Далі:                   5 req/сек стабільно (не більше)
Program.cs — Token Bucket Limiter
builder.Services.AddRateLimiter(options =>
{
    options.AddTokenBucketLimiter(
        policyName: "token-bucket",
        configureOptions: limiter =>
        {
            limiter.TokenLimit          = 50;
            limiter.TokensPerPeriod     = 10;
            limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
            limiter.AutoReplenishment   = true;
            limiter.QueueLimit          = 5;
        });
});

Анатомія коду:

  • TokenLimit = 50 — максимальна місткість відра. Навіть якщо клієнт не робив запитів годину — більше 50 токенів не накопичиться.
  • TokensPerPeriod = 10 та ReplenishmentPeriod = TimeSpan.FromSeconds(1) — кожну секунду у відро додається 10 токенів.
  • AutoReplenishment = true — поповнення відбувається автоматично у фоновому потоці.

Коли використовувати Token Bucket:

  • API, де очікується нерівномірний трафік (burst запити при старті застосунку клієнта).
  • SDK-клієнти, що відправляють дані батчами.
  • Сценарії, де невеликі спалахи є нормальним патерном використання.

Concurrency Limiter: для дорогих операцій

Concurrency Limiter обмежує не швидкість, а кількість одночасно активних запитів. Це принципово інша модель: нам не важливо, скільки запитів прийшло за хвилину — нам важливо, щоб у будь-який момент часу не більше N запитів виконувалося паралельно.

Чому це потрібно? Деякі операції надзвичайно дорогі: генерація PDF, обробка зображень, тренування ML-моделі. Якщо дозволити 100 таких операцій одночасно — сервер «впаде» через вичерпання CPU або RAM. Concurrency Limiter гарантує, що в обробці одночасно максимум N таких запитів.

PermitLimit = 3 (одночасних запитів)

→ Req1 [████████████████████] виконується 5 сек
→ Req2 [████████████████    ] виконується 4 сек
→ Req3 [████████████        ] виконується 3 сек
→ Req4 чекає в черзі...
→ Req5 чекає в черзі...
→ Req6 → 429 (черга заповнена)
         Після завершення Req3 → Req4 починається
Program.cs — Concurrency Limiter
builder.Services.AddRateLimiter(options =>
{
    options.AddConcurrencyLimiter(
        policyName: "concurrency",
        configureOptions: limiter =>
        {
            limiter.PermitLimit          = 5;
            limiter.QueueProcessingOrder = QueueProcessingOrder.NewestFirst;
            limiter.QueueLimit           = 10;
        });
});

Анатомія коду:

  • QueueProcessingOrder.NewestFirst — більш нові запити мають пріоритет. Це контрінтуїтивно, але має сенс: якщо Req1 чекає 30 секунд у черзі, користувач вже, мабуть, закрив застосунок. Обrobляти свіжіший Req10 — ефективніше.

Коли використовувати Concurrency Limiter:

  • ML inference ендпоінти.
  • Генерація документів (PDF, Excel).
  • Обробка відео або зображень.
  • Будь-яка операція, що тримає дорогий ресурс (cuda GPU) під час виконання.

2. Глобальне налаштування та підключення

Усі алгоритми вимагають двох кроків: реєстрація та підключення middleware.

Program.cs — повна структура
// 1. Реєстрація (у секції Services)
builder.Services.AddRateLimiter(options =>
{
    // Тут конфігуруємо алгоритми та globalLimiter
    // ...

    // Статус-код при відхиленні (за замовчуванням 503, правильніше 429)
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    // Глобальна функція-відповідь при відхиленні
    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.StatusCode  = 429;
        context.HttpContext.Response.ContentType = "application/json";

        // Стандартний заголовок Retry-After (RFC 7231)
        if (context.Lease.TryGetMetadata(
            MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        }

        await context.HttpContext.Response.WriteAsJsonAsync(new
        {
            error      = "Too many requests.",
            retryAfter = retryAfter.TotalSeconds,
            message    = "Please reduce your request frequency."
        }, token);
    };
});

var app = builder.Build();

// 2. Підключення middleware (порядок має значення!)
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();  // ← Після Auth, щоб знати UserId для per-user лімітів
app.MapControllers();

Порядок UseRateLimiter() — критично важливий. Якщо поставити його до UseAuthentication(), то HttpContext.User ще не буде заповнений, і per-user ліміти не зможуть визначити, хто саме зробив запит. Завжди — після auth middleware.

Застосування до маршрутів

Застосування policy до ендпоінтів
// Метод 1: через атрибут [EnableRateLimiting]
[EnableRateLimiting("sliding")]
[HttpGet("/api/products")]
public IActionResult GetProducts() => Ok(/* ... */);

// Метод 2: через Minimal API extension
app.MapGet("/api/products", () => Results.Ok())
    .RequireRateLimiting("sliding");

// Метод 3: вимкнути rate limiting для конкретного ендпоінту
app.MapGet("/health", () => Results.Ok())
    .DisableRateLimiting();

// Метод 4: застосувати до цілої групи маршрутів
var apiGroup = app.MapGroup("/api")
    .RequireRateLimiting("per-user-adaptive");

apiGroup.MapGet("/products", () => /* ... */);
apiGroup.MapGet("/orders",   () => /* ... */);

3. Partitioned Rate Limiting: ізоляція між клієнтами

Ось де починається справжня міць rate limiting. Глобальний ліміт «100 запитів на хвилину» — це один спільний лічильник для ВСІХ клієнтів. Якщо один активний клієнт використає всі 100 — решта отримає 429.

Partitioned Rate Limiting вирішує це: кожен клієнт отримує власний, незалежний лічильник. Якщо клієнт A вичерпав ліміт — клієнт B продовжує працювати нормально. Ключ till партиції — те, чим ми ідентифікуємо клієнта: IP-адреса, UserId, API Key ID.

Program.cs — Per-IP Sliding Window
builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("per-ip", httpContext =>
    {
        // Визначаємо ключ партиції
        // Якщо застосунок стоїть за проксі — X-Forwarded-For важливіший
        var ip = httpContext.Request.Headers["X-Forwarded-For"]
                     .FirstOrDefault()
                 ?? httpContext.Connection.RemoteIpAddress?.ToString()
                 ?? "unknown";

        // Беремо лише першу IP з можливого списку (проксі може додавати кілька)
        ip = ip.Split(',')[0].Trim();

        return RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey: $"ip:{ip}",
            factory: _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit       = 60,
                Window            = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 4,
                QueueLimit        = 0
            });
    });
});

Анатомія коду:

RateLimitPartition.GetSlidingWindowLimiter(partitionKey, factory) — ключова функція. partitionKey — унікальний ключ для цього клієнта. Для нього автоматично створюється окремий RateLimiter під час першого запиту. Якщо ключ вже існує — використовується той самий лімітер (той самий лічильник).

Префікс "ip:" у ключі — гарна практика, якщо у вас кілька різних схем. Це запобігає колізіям: userid "123" та IP "123" не будуть конфліктувати.

Адаптивний ліміт: різні правила для різних типів клієнтів

Реальні API зазвичай мають диференційовані ліміти. Анонімний відвідувач, авторизований безкоштовний користувач та корпоративний клієнт — всі mають різні очікування і різне навантаження.

Program.cs — Адаптивний per-user ліміт
builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("adaptive", httpContext =>
    {
        var user = httpContext.User;

        // Сценарій 1: Аутентифікований користувач
        if (user.Identity?.IsAuthenticated == true)
        {
            var userId = user.FindFirst(ClaimTypes.NameIdentifier)!.Value;
            var plan   = user.FindFirst("subscription_plan")?.Value ?? "free";

            // Параметри залежать від тарифного плану
            var (limit, window) = plan switch
            {
                "enterprise" => (int.MaxValue, TimeSpan.FromDays(1)),
                "pro"        => (5_000, TimeSpan.FromHours(1)),
                "starter"    => (500,   TimeSpan.FromHours(1)),
                _            => (100,   TimeSpan.FromHours(1)) // free
            };

            if (plan == "enterprise")
                return RateLimitPartition.GetNoLimiter($"enterprise:{userId}");

            return RateLimitPartition.GetTokenBucketLimiter(
                partitionKey: $"user:{userId}:{plan}",
                factory: _ => new TokenBucketRateLimiterOptions
                {
                    TokenLimit          = limit,
                    TokensPerPeriod     = limit / 60, // рівномірно по хвилинах
                    ReplenishmentPeriod = TimeSpan.FromMinutes(1),
                    AutoReplenishment   = true,
                    QueueLimit          = 10
                });
        }

        // Сценарій 2: Анонімний — суворіший ліміт, за IP
        var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";

        return RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: $"anon:{ip}",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 20,
                Window      = TimeSpan.FromMinutes(1),
                QueueLimit  = 0
            });
    });
});

Чому plan enterprise отримує GetNoLimiter? Enterprise-клієнти зазвичай платять за необмежений доступ. GetNoLimiter — спеціальна партиція, що завжди пропускає запит. Це ефективніше, ніж встановлювати PermitLimit = int.MaxValue, бо не створює лічильника взагалі.

Чому TokenBucket для платних планів? Платні клієнти часто мають нерівномірний трафік: іноді роблять batch-запити. Token Bucket дозволяє накопичувати токени і зробити burst, що є більш зручним для реальних клієнтів.


4. GlobalLimiter: захист від масових атак

Навіть при per-IP лімітах можлива атака з тисяч різних IP-адрес (ботнет або розподілена атака). GlobalLimiter — це додатковий рівень захисту, що застосовується до всіх запитів незалежно від конкретних policy.

Program.cs — GlobalLimiter
builder.Services.AddRateLimiter(options =>
{
    // GlobalLimiter застосовується ДО перевірки конкретних policy
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
        httpContext =>
        {
            var ip = httpContext.Connection.RemoteIpAddress?.ToString()
                     ?? "unknown";

            return RateLimitPartition.GetSlidingWindowLimiter(
                partitionKey: $"global:{ip}",
                factory: _ => new SlidingWindowRateLimiterOptions
                {
                    PermitLimit       = 1_000, // Верхня межа для будь-якого IP
                    Window            = TimeSpan.FromMinutes(1),
                    SegmentsPerWindow = 6,
                    QueueLimit        = 0
                });
        });

    // Локальні policy додаються поверх GlobalLimiter
    options.AddPolicy("per-user", httpContext => /* ... */ null!);
});

При запиті перевіряється спочатку GlobalLimiter, потім конкретна policy. Щоб пройти обидва рівні, запит має задовольнити обидва ліміти.


5. Стандартні HTTP заголовки у відповідях

Коли клієнт отримує 429, він має знати, коли можна повторити спробу. Для цього існують стандартні HTTP заголовки:

Retry-After (RFC 7231) — найважливіший. Вказує, через скільки секунд клієнт може повторити запит. Добре спроектовані HTTP-клієнти та SDK автоматично читають цей заголовок та чекають вказаний час.

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710000060

{"error": "Too many requests", "retryAfter": 30}
Кастомна відповідь 429 з усіма заголовками
options.OnRejected = async (context, token) =>
{
    var response = context.HttpContext.Response;
    response.StatusCode  = 429;
    response.ContentType = "application/json";

    // Retry-After зі стандартного метадата RateLimiter
    var retryAfter = 60.0;
    if (context.Lease.TryGetMetadata(
        MetadataName.RetryAfter, out var retryAfterSpan))
    {
        retryAfter = retryAfterSpan.TotalSeconds;
    }

    response.Headers["Retry-After"]          = ((int)retryAfter).ToString();
    response.Headers["X-RateLimit-Limit"]    = "100";
    response.Headers["X-RateLimit-Remaining"] = "0";
    response.Headers["X-RateLimit-Reset"]    =
        DateTimeOffset.UtcNow.AddSeconds(retryAfter)
            .ToUnixTimeSeconds().ToString();

    await response.WriteAsJsonAsync(new
    {
        type   = "https://tools.ietf.org/html/rfc6585#section-4",
        title  = "Too Many Requests",
        status = 429,
        detail = "API rate limit exceeded. Reduce request frequency.",
        retryAfterSeconds = (int)retryAfter
    }, cancellationToken: token);
};

6. Whitelist та Bypass: виключення з обмежень

Не всі клієнти мають обмежуватися. Внутрішні сервіси, health check системи, адміністративні інструменти — вони мають отримувати доступ без обмежень.

Program.cs — Bypass для внутрішніх клієнтів
builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("smart-limit", httpContext =>
    {
        // Bypass 1: Внутрішні сервіси за спеціальним заголовком
        // Заголовок має містити секретний токен, відомий лише внутрішнім сервісам
        if (httpContext.Request.Headers.TryGetValue(
            "X-Internal-Service-Token", out var token) &&
            token == "internal-secret-token")
        {
            return RateLimitPartition.GetNoLimiter("internal");
        }

        // Bypass 2: Адміністратори
        if (httpContext.User.IsInRole("admin"))
        {
            return RateLimitPartition.GetNoLimiter("admin");
        }

        // Bypass 3: Конкретні IP адреси (whitelist)
        var ip = httpContext.Connection.RemoteIpAddress?.ToString();
        var trustedIps = new[] { "10.0.0.1", "192.168.1.100" };
        if (ip is not null && trustedIps.Contains(ip))
            return RateLimitPartition.GetNoLimiter($"trusted:{ip}");

        // Стандартний ліміт для інших
        return RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey: $"default:{ip ?? "unknown"}",
            factory: _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit       = 60,
                Window            = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 4
            });
    });
});
Безпека bypass через заголовки. Перевіряйте X-Internal-Service-Token через CryptographicOperations.FixedTimeEquals, а не звичайне порівняння рядків, щоб уникнути timing attacks. Також: внутрішній токен має зберігатися у secrets, не у config-файлах.

7. Проблема розподілених систем: чому in-memory недостатньо

Наведені вище рішення мають критичне обмеження: лічильники зберігаються в пам'яті процесу. Якщо ваш застосунок запущений у 3 екземплярах за load balancer, кожен матиме власний незалежний лічильник. Клієнт, що надсилає 300 запитів на хвилину, може розподілити їх по трьом екземплярам — по 100 на кожен. Ліміт 100 req/хв буде тричі «виконаний», але реальна кількість запитів — 300.

Load Balancer
├── Instance 1 (ліміт 100, лічильник: 100) → 429!
│   ↑ клієнт вже вичерпав Instance 1
├── Instance 2 (ліміт 100, лічильник: 100) → 429!
└── Instance 3 (ліміт 100, лічильник: 100) → 429!

Реально пройшло: 300 запитів — утричі більше ліміту!

Для горизонтально масштабованих систем потрібен централізований стан. Найкращий варіант — Redis: in-memory сховище з атомарними операціями та TTL.

Services/RedisRateLimiterStore.cs
using StackExchange.Redis;

public class RedisRateLimiterStore
{
    private readonly IDatabase _db;

    public RedisRateLimiterStore(IConnectionMultiplexer redis)
        => _db = redis.GetDatabase();

    /// <summary>
    /// Атомарний Fixed Window Rate Limiter через Redis Lua-скрипт.
    /// Lua вирішує race condition: INCR та EXPIRE — одна атомарна операція.
    /// </summary>
    public async Task<RateLimitResult> CheckFixedWindowAsync(
        string partitionKey,
        int limit,
        TimeSpan window)
    {
        // Lua-скрипт виконується атомарно (Redis single-threaded)
        // INCR key → збільшує лічильник
        // Якщо це перший запит (current == 1) → встановлюємо TTL
        const string luaScript = @"
            local current = redis.call('INCR', KEYS[1])
            if current == 1 then
                redis.call('EXPIRE', KEYS[1], ARGV[1])
            end
            local ttl = redis.call('TTL', KEYS[1])
            return {current, ttl}";

        var result = (RedisValue[])await _db.ScriptEvaluateAsync(
            luaScript,
            keys:   [new RedisKey(partitionKey)],
            values: [new RedisValue((int)window.TotalSeconds)]);

        var current   = (int)result[0];
        var ttlSeconds = (int)result[1];

        return new RateLimitResult(
            IsAllowed: current <= limit,
            Current:   current,
            Limit:     limit,
            ResetAfter: TimeSpan.FromSeconds(ttlSeconds));
    }
}

public record RateLimitResult(
    bool     IsAllowed,
    int      Current,
    int      Limit,
    TimeSpan ResetAfter);

Чому саме Lua-скрипт? Redis виконує Lua атомарно — між INCR та EXPIRE не може вставитися інший клієнт. Якщо робити ці операції окремими командами (не в Lua) — можлива race condition: два одночасних запити обидва отримають current == 1 та обидва встановлять TTL, але один з них «перезапише» лічильник іншого.

Middleware/RedisRateLimitMiddleware.cs — використання
public class RedisRateLimitMiddleware
{
    private readonly RequestDelegate      _next;
    private readonly RedisRateLimiterStore _store;
    private readonly ILogger<RedisRateLimitMiddleware> _logger;

    public RedisRateLimitMiddleware(
        RequestDelegate next,
        RedisRateLimiterStore store,
        ILogger<RedisRateLimitMiddleware> logger)
    {
        _next   = next;
        _store  = store;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext ctx)
    {
        var key    = GetPartitionKey(ctx);
        var result = await _store.CheckFixedWindowAsync(key, limit: 60,
            window: TimeSpan.FromMinutes(1));

        // Завжди повертаємо інформативні заголовки
        ctx.Response.Headers["X-RateLimit-Limit"]     = result.Limit.ToString();
        ctx.Response.Headers["X-RateLimit-Remaining"] =
            Math.Max(0, result.Limit - result.Current).ToString();
        ctx.Response.Headers["X-RateLimit-Reset"]     =
            ((long)DateTimeOffset.UtcNow.Add(result.ResetAfter)
                .ToUnixTimeSeconds()).ToString();

        if (!result.IsAllowed)
        {
            _logger.LogWarning(
                "Rate limit exceeded for {Key}: {Current}/{Limit}",
                key, result.Current, result.Limit);

            ctx.Response.StatusCode  = 429;
            ctx.Response.ContentType = "application/json";
            ctx.Response.Headers["Retry-After"] =
                ((int)result.ResetAfter.TotalSeconds).ToString();

            await ctx.Response.WriteAsJsonAsync(new
            {
                error = "Rate limit exceeded.",
                retryAfter = (int)result.ResetAfter.TotalSeconds
            });
            return;
        }

        await _next(ctx);
    }

    private static string GetPartitionKey(HttpContext ctx)
    {
        if (ctx.User.Identity?.IsAuthenticated == true)
            return $"rl:user:{ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value}";

        var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return $"rl:ip:{ip}";
    }
}
Program.cs — реєстрація Redis middleware
builder.Services.AddSingleton<IConnectionMultiplexer>(
    ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddSingleton<RedisRateLimiterStore>();

// ...
app.UseMiddleware<RedisRateLimitMiddleware>();

8. Спеціалізовані обмеження для критичних ендпоінтів

Не всі ендпоінти потребують однакових стратегій. Форма логіну, реєстрації та скидання пароля — особливо чутливі до brute-force атак, тому для них встановлюють набагато жорсткіші ліміти.

Program.cs — суворий ліміт для /auth/login
builder.Services.AddRateLimiter(options =>
{
    // Логін: суворий per-IP (може бути клієнта за NAT)
    // Допускаємо 5 спроб за 15 хвилин — цього достатньо для ручного логіну
    options.AddFixedWindowLimiter("auth-login", limiter =>
    {
        limiter.PermitLimit = 5;
        limiter.Window      = TimeSpan.FromMinutes(15);
        limiter.QueueLimit  = 0; // Без черги для auth — безпечніше відхиляти
    });

    // Реєстрація: трохи ліберальніше, але все одно суворо
    options.AddFixedWindowLimiter("auth-register", limiter =>
    {
        limiter.PermitLimit = 3;
        limiter.Window      = TimeSpan.FromHours(1);
    });

    // Password reset: email дозволяє enumerate users, тому обмежуємо
    options.AddFixedWindowLimiter("auth-reset", limiter =>
    {
        limiter.PermitLimit = 3;
        limiter.Window      = TimeSpan.FromHours(1);
    });
});

// Застосування
app.MapPost("/auth/login",    LoginHandler).RequireRateLimiting("auth-login");
app.MapPost("/auth/register", RegisterHandler).RequireRateLimiting("auth-register");
app.MapPost("/auth/forgot",   ForgotHandler).RequireRateLimiting("auth-reset");
Для auth-ендпоінтів також рекомендується exponential backoff response time — кожен наступний провальний спробу відповідає повільніше. Це не реалізується через вбудований RateLimiter, але може бути додано через кастомний middleware з Task.Delay.

9. Тестування Rate Limiting

Перевірка rate limiting — обов'язковий крок перед деплоєм у production.

Tests/RateLimitingTests.cs
public class RateLimitingTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public RateLimitingTests(WebApplicationFactory<Program> factory)
        => _factory = factory;

    [Fact]
    public async Task FixedWindow_RejectsAfterLimit()
    {
        var client = _factory.CreateClient();

        // Надсилаємо запити до/після ліміту
        for (var i = 0; i < 10; i++)
        {
            var response = await client.GetAsync("/api/products");
            response.StatusCode.Should().Be(HttpStatusCode.OK);
        }

        // 11-й запит — повинен бути відхилений
        var rejected = await client.GetAsync("/api/products");
        rejected.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);

        // Перевіряємо наявність Retry-After заголовку
        rejected.Headers.Should()
            .ContainKey("Retry-After");
    }

    [Fact]
    public async Task DifferentIPs_HaveIndependentCounters()
    {
        // Клієнт 1 симулює IP 1.1.1.1
        var client1 = _factory.WithWebHostBuilder(b =>
            b.ConfigureTestServices(services =>
                services.PostConfigure<TestOptions>(o =>
                    o.RemoteIpAddress = "1.1.1.1")))
            .CreateClient();

        // Клієнт 2 симулює IP 2.2.2.2
        var client2 = _factory.WithWebHostBuilder(b =>
            b.ConfigureTestServices(services =>
                services.PostConfigure<TestOptions>(o =>
                    o.RemoteIpAddress = "2.2.2.2")))
            .CreateClient();

        // Client1 вичерпує ліміт
        for (var i = 0; i < 10; i++)
            await client1.GetAsync("/api/products");

        // Client2 має власний незалежний лічильник
        var response = await client2.GetAsync("/api/products");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

10. Практичні завдання

Рівень 1: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


11. Резюме

Rate limiting — це не функція, яку «добре б мати». Це базова відповідальність перед своїми користувачами: гарантія, що один зловмисний клієнт не знищить досвід для всіх решти.

Вибір алгоритму — архітектурне рішення

Fixed Window — просто. Sliding Window — справедливо. Token Bucket — burst-friendly. Concurrency — для дорогих операцій. Кожен оптимізований для свого типу трафіку.

Partition = ізоляція між клієнтами

RateLimitPartition.GetXxx(partitionKey, factory). Один клієнт не може заблокувати іншого. Ключ — userId, IP, або API Key ID.

Redis для scale-out

In-memory лічильники не масштабуються горизонтально. Redis + атомарний Lua-скрипт = централізований ліміт для будь-якої кількості інстанцій.

429 з Retry-After = good UX

Клієнти, що поважають HTTP-стандарти, читають Retry-After та чекають. Без цього заголовку — вони будуть ретраити і посилювати навантаження.

Далі: наступна стаття — Refresh Token Rotation — безпечна реалізація довгострокових сесій: token families, reuse detection та захист від крадіжки токенів.

Copyright © 2026