Rate Limiting та Throttling в ASP.NET Core
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 Force | 10 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 на межі вікна».
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 слоти доступні ✓
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/сек стабільно (не більше)
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 починається
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.
// 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.
Застосування до маршрутів
// Метод 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.
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ають різні очікування і різне навантаження.
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.
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}
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 системи, адміністративні інструменти — вони мають отримувати доступ без обмежень.
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
});
});
});
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.
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, але один з них «перезапише» лічильник іншого.
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}";
}
}
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddSingleton<RedisRateLimiterStore>();
// ...
app.UseMiddleware<RedisRateLimitMiddleware>();
8. Спеціалізовані обмеження для критичних ендпоінтів
Не всі ендпоінти потребують однакових стратегій. Форма логіну, реєстрації та скидання пароля — особливо чутливі до brute-force атак, тому для них встановлюють набагато жорсткіші ліміти.
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");
Task.Delay.9. Тестування Rate Limiting
Перевірка rate limiting — обов'язковий крок перед деплоєм у production.
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: Базовий
Налаштуйте один і той самий ліміт (10 req/хв) через усі 4 алгоритми та порівняйте поведінку:
- Створіть 4 ендпоінти:
/fixed,/sliding,/token,/concurrent - Напишіть PowerShell скрипт, що надсилає 15 запитів підряд за 30 секунд
- Зафіксуйте: який запит (1-15) отримав 429 для кожного алгоритму?
- Перевірте крайовий ефект для Fixed Window: надішліть 8 запитів в кінці хвилини та 8 на початку наступної хвилини
- Зробіть висновок: який алгоритм найбільш «справедливий» і чому?
- Зареєструйте трьох тестових користувачів з різними планами:
free,starter,pro - Налаштуйте
AddPolicy("adaptive")з різними лімітами залежно від claimsubscription_plan - Протестуйте: free отримує 429 раніше, ніж starter; pro — набагато пізніше
- Анонімний запит — суворіший ліміт, ніж free
- Додайте заголовки
X-RateLimit-Limit,X-RateLimit-Remainingдо всіх відповідей
Рівень 2: Проєктування
- Налаштуйте жорсткі ліміти:
/auth/login— 5 req/15хв,/auth/forgot— 3 req/год - При 429 на login — повертати
retryAfterMinutesзамість секунд - Після 3 невдалих спроб входу — додатково збільшувати час відповіді (150ms, 300ms, 600ms)
- Логуйте всі 429 на auth ендпоінтах з IP-адресою та user agent
- Тест: переконайтесь, що legitimate user не заблокований при нормальному використанні
- Запустіть Redis (
docker run -d -p 6379:6379 redis) - Реалізуйте
RedisRateLimiterStoreз Lua-скриптом - Запустіть 2 екземпляри вашого API (
--urls http://+:7001та7002) - Надішліть 100 запитів, чергуючи між двома екземплярами
- Переконайтесь: спільний ліміт дотримується — не 100 на кожен, а 100 разом
Рівень 3: Архітектура
Побудуйте enterprise-рівень rate limiting:
- GlobalLimiter: 1000 req/хв per IP — захист від DDoS
- Per-endpoint: різні ліміти для
/api/search(дорогий),/api/products(дешевий) - Per-user plan: free/starter/pro/enterprise — різні квоти
- Auth захист: login, register, forgot-password — окремі суворі ліміти
- Метрики: логуємо кожен 429, IP, endpoint, plan — dashborad у якомусь observability tool (або просто файл логів)
11. Резюме
Rate limiting — це не функція, яку «добре б мати». Це базова відповідальність перед своїми користувачами: гарантія, що один зловмисний клієнт не знищить досвід для всіх решти.
Вибір алгоритму — архітектурне рішення
Partition = ізоляція між клієнтами
RateLimitPartition.GetXxx(partitionKey, factory). Один клієнт не може заблокувати іншого. Ключ — userId, IP, або API Key ID.Redis для scale-out
429 з Retry-After = good UX
Retry-After та чекають. Без цього заголовку — вони будуть ретраити і посилювати навантаження.Далі: наступна стаття — Refresh Token Rotation — безпечна реалізація довгострокових сесій: token families, reuse detection та захист від крадіжки токенів.
API Keys аутентифікація в ASP.NET Core
Генерація, зберігання та валідація API ключів в ASP.NET Core Minimal API: кастомний AuthenticationHandler, хешування ключів, scopes, rate limiting, ротація та аудит.
Refresh Token Rotation в ASP.NET Core
Повна реалізація Refresh Token Rotation: JWT access tokens, rotating refresh tokens, сімейство токенів (token family), sliding expiration, захист від крадіжки токенів та відкликання.