API Rate Limiting — .NET 8'de Yerleşik Çözüm
API Rate Limiting — .NET 8'de Yerleşik Çözüm
Neden Rate Limiting?
API'niz internet üzerindeyse şu senaryolar kaçınılmazdır:
Senaryo 1 — Kötü niyetli kullanım:
Bir bot saniyede 10.000 istek gönderir
→ Sunucu kaynakları tükenir
→ Gerçek kullanıcılar yanıt alamaz
Senaryo 2 — Hatalı istemci kodu:
Bir müşterinin uygulaması sonsuz döngüde API çağrısı yapar
→ Kota aşılır, fatura patlar
→ Diğer müşteriler etkilenir
Senaryo 3 — Credential stuffing:
Binlerce farklı şifre kombinasyonu denenir
→ Brute force ile hesap ele geçirilir
Senaryo 4 — DDoS:
Dağıtık saldırı ile API'niz çöker
→ İş sürekliliği sekteye uğrar
Rate limiting bu senaryoların hepsine karşı ilk savunma hattıdır. .NET 7'ye kadar bu için AspNetCoreRateLimit gibi harici paketlere ihtiyaç vardı. .NET 8 ile birlikte System.Threading.RateLimiting namespace'i ve Microsoft.AspNetCore.RateLimiting middleware yerleşik olarak geldi.
Temel Kavramlar
Rate Limiting Nerede Durmalı?
İstemci
↓
[CDN / WAF] ← İdeal: Burada, hiç sunucuya ulaşmadan engellenir
↓
[Reverse Proxy] ← İyi: Nginx, IIS üzerinde
↓
[API Gateway] ← İyi: APIM, Kong
↓
[.NET Middleware] ← Bu yazının konusu
↓
[Controller]
↓
[Veritabanı]
.NET middleware seviyesindeki rate limiting CDN'in yerini tutmaz. CDN koruması olmayan API'lerde veya internal API'lerde .NET middleware son derece değerlidir.
Temel Metrikler
Limit → Pencerede kaç isteğe izin veriliyor?
Window → Pencere ne kadar süre?
Partition → Limit kim için geçerli? (IP, kullanıcı, API key)
Queue → Limit aşılınca istek beklemeye alınsın mı?
Algoritma Seçimi
1. Fixed Window — Sabit Pencere
En basit algoritma. Belirli bir zaman penceresinde sabit istek limiti.
Pencere: 1 dakika, Limit: 10
00:00 ──────────────── 01:00 ──────────────── 02:00
│ │ │
├── 10 istek OK ├── 10 istek OK │
└── 11. istek → 429 └── 11. istek → 429
Sorun — Window boundary saldırısı:
00:59 → 10 istek gönder (son saniyede)
01:00 → 10 istek daha gönder (yeni pencere)
Sonuç: 2 saniyede 20 istek — limit aşıldı ama engellenemedi
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", policy =>
{
policy.PermitLimit = 10;
policy.Window = TimeSpan.FromMinutes(1);
policy.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
policy.QueueLimit = 0; // Kuyruğa alma
});
});
Ne zaman kullanılır:
✅ Basit API koruması
✅ Webhook endpoint'leri
✅ Düşük trafikli API'ler
❌ Window boundary saldırısı riski kabul edilemiyorsa
2. Sliding Window — Kayan Pencere
Fixed window'un boundary sorununu çözer. Pencere gerçek zamanlı olarak kayar.
Pencere: 1 dakika, Limit: 10
Şu an: 00:45
Son 1 dakika: 00:45 → 23:45 arası istekler sayılır
Bu aralıkta 7 istek yapılmış → 3 istek daha yapılabilir
Şu an: 00:46
Son 1 dakika: 00:46 → 23:46 arası istekler sayılır
Pencere kaydı, en eski istek düştü → 4 istek daha yapılabilir
builder.Services.AddRateLimiter(options =>
{
options.AddSlidingWindowLimiter("sliding", policy =>
{
policy.PermitLimit = 100;
policy.Window = TimeSpan.FromMinutes(1);
policy.SegmentsPerWindow = 6; // Dakikayı 6 segmente böl (10'ar saniye)
policy.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
policy.QueueLimit = 10;
});
});
Segment mantığı:
Window: 60 saniye, SegmentsPerWindow: 6
→ Her segment 10 saniye
→ Daha hassas tracking, daha az bellek kullanımı
(tam kayan pencere yerine)
Ne zaman kullanılır:
✅ Genel API rate limiting
✅ Kullanıcı bazlı limitler
✅ Fixed window yetersizse
❌ Yüksek bellek hassasiyeti olan ortamlar
3. Token Bucket — Token Kovası
Bir kova token'la dolar. Her istek bir token harcar. Kova belirli hızda dolar.
Kova kapasitesi: 10 token
Dolma hızı: saniyede 2 token
Başlangıç: 10 token (dolu)
10 istek gönder → 0 token
5 saniye bekle → 10 token yeniden doldu (max)
2 istek gönder → 8 token
Avantajı: Ani burst trafiğine izin verir, ama sürdürülebilir hız sınırlıdır.
builder.Services.AddRateLimiter(options =>
{
options.AddTokenBucketLimiter("token-bucket", policy =>
{
policy.TokenLimit = 100; // Kova kapasitesi
policy.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
policy.TokensPerPeriod = 20; // Her 10 saniyede 20 token ekle
policy.AutoReplenishment = true; // Otomatik doldurma
policy.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
policy.QueueLimit = 5;
});
});
Ne zaman kullanılır:
✅ Uygulama başladığında burst trafiğine izin vermek
✅ Ortalama hız sınırı gerekli ama anlık burst OK
✅ Mobil uygulamalar — bağlantı kurulunca birden fazla istek gelir
❌ Kesin "X istek / dakika" garantisi gerekiyorsa
4. Concurrency Limiter — Eş Zamanlı İstek
Kaç isteğin aynı anda işlenebileceğini sınırlar. Zaman penceresine değil, anlık eş zamanlılığa bakar.
Limit: 5 eş zamanlı istek
İstek 1 → İşleniyor...
İstek 2 → İşleniyor...
İstek 3 → İşleniyor...
İstek 4 → İşleniyor...
İstek 5 → İşleniyor...
İstek 6 → 429 (veya kuyrukta bekle)
İstek 1 tamamlandı → İstek 6 işlenmeye başlar
builder.Services.AddRateLimiter(options =>
{
options.AddConcurrencyLimiter("concurrency", policy =>
{
policy.PermitLimit = 10;
policy.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
policy.QueueLimit = 5;
});
});
Ne zaman kullanılır:
✅ Yoğun kaynak tüketen endpoint'ler (rapor oluşturma, dosya işleme)
✅ Veritabanı bağlantı havuzunu korumak
✅ External API çağrısı yapan endpoint'ler
❌ Hız (rate) değil, eş zamanlılık sorunu yoksa
Partitioned Rate Limiting — Kullanıcı Bazlı Limit
Aynı limiti herkese uygulamak çoğu zaman yanlıştır. Premium kullanıcılar daha fazla, anonim kullanıcılar daha az istek yapabilmeli.
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
// Kimlik doğrulanmış kullanıcı
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var isPremium = context.User.IsInRole("Premium");
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: $"user:{userId}",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = isPremium ? 1000 : 100, // Premium 10x daha fazla
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
}
// API key kullanan istemci
if (context.Request.Headers.TryGetValue("X-Api-Key", out var apiKey))
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"apikey:{apiKey}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 500,
Window = TimeSpan.FromMinutes(1)
});
}
// Anonim — IP bazlı, en kısıtlı
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"anon:{ip}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
});
});
});
Named Policy — Endpoint Bazlı Limit
Her endpoint farklı bir rate limiting politikası gerektirebilir:
builder.Services.AddRateLimiter(options =>
{
// Login endpoint — brute force koruması
options.AddFixedWindowLimiter("auth-strict", policy =>
{
policy.PermitLimit = 5;
policy.Window = TimeSpan.FromMinutes(15);
policy.QueueLimit = 0;
});
// Genel API — kullanıcı dostu
options.AddSlidingWindowLimiter("api-standard", policy =>
{
policy.PermitLimit = 60;
policy.Window = TimeSpan.FromMinutes(1);
policy.SegmentsPerWindow = 6;
policy.QueueLimit = 5;
});
// Rapor endpoint — yoğun kaynak
options.AddConcurrencyLimiter("report-limiter", policy =>
{
policy.PermitLimit = 3;
policy.QueueLimit = 2;
});
// Webhook — esnek burst
options.AddTokenBucketLimiter("webhook", policy =>
{
policy.TokenLimit = 50;
policy.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
policy.TokensPerPeriod = 10;
policy.AutoReplenishment = true;
});
// 429 yanıtı
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Çok Fazla İstek",
Detail = "İstek limitinizi aştınız. Lütfen daha sonra tekrar deneyin.",
Extensions = { ["retryAfter"] = context.Lease.TryGetMetadata(
MetadataName.RetryAfter, out var ra) ? (int)ra.TotalSeconds : 60 }
}, ct);
};
});
// Controller'da politika uygulama
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
[HttpPost("login")]
[EnableRateLimiting("auth-strict")] // 5 deneme / 15 dakika
public async Task<IActionResult> Login(LoginRequest request) { ... }
[HttpPost("forgot-password")]
[EnableRateLimiting("auth-strict")] // Aynı politika
public async Task<IActionResult> ForgotPassword(ForgotPasswordRequest request) { ... }
}
[ApiController]
[Route("api/products")]
[EnableRateLimiting("api-standard")] // Controller seviyesinde — tüm action'lara
public class ProductsController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() { ... }
[HttpGet("{id}")]
public async Task<IActionResult> GetById(Guid id) { ... }
[HttpGet("export")]
[EnableRateLimiting("report-limiter")] // Action override — daha sıkı
public async Task<IActionResult> Export() { ... }
[HttpGet("health")]
[DisableRateLimiting] // Rate limiting yok — health check
public IActionResult Health() => Ok();
}
Chained Rate Limiter — Zincirleme
Birden fazla limiti birleştirin — her ikisi de sağlanmalı:
// Hem saniyede 5, hem dakikada 100 istek limiti
var chainedLimiter = PartitionedRateLimiter.CreateChained(
PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var userId = GetUserId(context);
return RateLimitPartition.GetFixedWindowLimiter(
$"perSecond:{userId}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromSeconds(1)
});
}),
PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var userId = GetUserId(context);
return RateLimitPartition.GetFixedWindowLimiter(
$"perMinute:{userId}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
});
})
);
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = chainedLimiter;
});
Response Headers — İstemciye Bilgi Ver
İstemciler ne kadar limit kaldığını bilmeli. Bu sayede akıllı retry stratejisi uygulayabilirler.
// RateLimitHeadersMiddleware.cs
public class RateLimitHeadersMiddleware
{
private readonly RequestDelegate _next;
public RateLimitHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
var rateLimitFeature = context.Features.Get<IRateLimiterStatisticsFeature>();
if (rateLimitFeature is not null)
{
context.Response.Headers["X-RateLimit-Limit"] =
rateLimitFeature.CurrentStatistics.CurrentAvailablePermits.ToString();
context.Response.Headers["X-RateLimit-Remaining"] =
rateLimitFeature.CurrentStatistics.CurrentAvailablePermits.ToString();
context.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow
.Add(TimeSpan.FromMinutes(1))
.ToUnixTimeSeconds()
.ToString();
}
}
}
// Extension
public static class RateLimitHeadersExtensions
{
public static IApplicationBuilder UseRateLimitHeaders(
this IApplicationBuilder app)
=> app.UseMiddleware<RateLimitHeadersMiddleware>();
}
İstemci bu header'larla ne yapar:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1735689600
HTTP/1.1 429 Too Many Requests
Retry-After: 47
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1735689647
IP Whitelist — Bazı İstemcileri Muaf Tutmak
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// İç ağ ve monitoring araçları limit dışı
var whitelistedIps = new HashSet<string>
{
"10.0.0.1", // İç monitoring
"10.0.0.5", // CI/CD runner
"192.168.1.0", // İç ağ
"127.0.0.1" // Localhost
};
if (whitelistedIps.Contains(ip))
{
// Sınırsız erişim — NoLimiter
return RateLimitPartition.GetNoLimiter(ip);
}
return RateLimitPartition.GetSlidingWindowLimiter(ip,
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6
});
});
});
Distributed Rate Limiting — Redis ile
Varsayılan .NET rate limiter tek instance için in-memory çalışır. Birden fazla uygulama instance'ında partition key'ler paylaşılmaz — her instance kendi sayacını tutar.
Çözüm: Redis backed rate limiter.
dotnet add package RedisRateLimiting
using RedisRateLimiting;
using StackExchange.Redis;
var redisConnection = ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis")!);
builder.Services.AddRateLimiter(options =>
{
options.AddRedisSlidingWindowLimiter("distributed", policy =>
{
policy.ConnectionMultiplexerFactory = () => redisConnection;
policy.PermitLimit = 100;
policy.Window = TimeSpan.FromMinutes(1);
});
options.AddRedisTokenBucketLimiter("distributed-burst", policy =>
{
policy.ConnectionMultiplexerFactory = () => redisConnection;
policy.TokenLimit = 200;
policy.TokensPerPeriod = 50;
policy.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
});
});
Ne zaman Redis gereklidir:
Tek instance API → In-memory yeterli
Load balanced API → Redis zorunlu
Kubernetes pod'ları → Redis zorunlu
Horizontal scaling → Redis zorunlu
Logging ve Monitoring
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (context, ct) =>
{
var httpContext = context.HttpContext;
var logger = httpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
var ip = httpContext.Connection.RemoteIpAddress?.ToString();
var userId = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var path = httpContext.Request.Path;
var method = httpContext.Request.Method;
// Metrik sayacı artır — Prometheus ile izlenebilir
RateLimitMetrics.RejectedRequests.Add(1,
new KeyValuePair<string, object?>("path", path),
new KeyValuePair<string, object?>("method", method));
logger.LogWarning(
"Rate limit aşıldı. IP: {IP} | UserId: {UserId} | {Method} {Path}",
ip, userId ?? "anonymous", method, path);
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Çok Fazla İstek",
Detail = "İstek limitinizi aştınız."
}, ct);
};
});
// Prometheus metrikleri
public static class RateLimitMetrics
{
public static readonly Counter<long> RejectedRequests =
Meter.CreateCounter<long>(
"api_rate_limit_rejected_total",
description: "Rate limit nedeniyle reddedilen toplam istek sayısı");
private static readonly Meter Meter = new("MyApp.RateLimit");
}
Test Etmek
// RateLimitingTests.cs
public class RateLimitingTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public RateLimitingTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Login_ShouldReturn429_AfterFiveAttempts()
{
// Arrange
var client = _factory.CreateClient();
var loginRequest = new { Email = "test@test.com", Password = "wrong" };
// Act — 5 başarısız deneme
for (int i = 0; i < 5; i++)
{
var response = await client.PostAsJsonAsync("/api/auth/login", loginRequest);
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
// 6. denemede rate limit devreye girmeli
var rateLimitedResponse = await client.PostAsJsonAsync("/api/auth/login", loginRequest);
// Assert
rateLimitedResponse.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
rateLimitedResponse.Headers.Should().ContainKey("Retry-After");
}
[Fact]
public async Task Api_ShouldReturn200_WhenWithinLimit()
{
// Arrange
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Api-Key", "valid-test-key");
// Act — limit içinde kal
for (int i = 0; i < 10; i++)
{
var response = await client.GetAsync("/api/products");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
}
Program.cs — Tam Kurulum
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
// 1. Global limiter — tüm isteklere uygulanır
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var isPremium = context.User.IsInRole("Premium");
return RateLimitPartition.GetSlidingWindowLimiter($"user:{userId}",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = isPremium ? 1000 : 100,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6
});
}
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (ip is "127.0.0.1" or "::1")
return RateLimitPartition.GetNoLimiter(ip);
return RateLimitPartition.GetFixedWindowLimiter($"anon:{ip}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1)
});
});
// 2. Named politikalar
options.AddFixedWindowLimiter("auth-strict", p =>
{
p.PermitLimit = 5;
p.Window = TimeSpan.FromMinutes(15);
});
options.AddConcurrencyLimiter("heavy-operations", p =>
{
p.PermitLimit = 3;
p.QueueLimit = 2;
});
// 3. Rejection handler
options.OnRejected = async (context, ct) =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogWarning(
"Rate limit: {IP} | {Path}",
context.HttpContext.Connection.RemoteIpAddress,
context.HttpContext.Request.Path);
context.HttpContext.Response.StatusCode = 429;
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Çok Fazla İstek",
Detail = "Lütfen daha sonra tekrar deneyin."
}, ct);
};
});
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter(); // Auth'tan sonra — kullanıcı bilgisi gerekli
app.UseRateLimitHeaders(); // Header'ları ekle
app.MapControllers();
app.Run();
Denetim Listesi
✅ Global limiter — tüm endpoint'ler korunuyor
✅ Auth endpoint'leri için ayrı, sıkı politika var
✅ Ağır operasyonlar (rapor, export) concurrency limiter ile korunuyor
✅ Premium kullanıcılar için daha yüksek limit tanımlandı
✅ İç ağ IP'leri whitelist'e alındı
✅ OnRejected handler — 429 + Problem Details + Retry-After
✅ Retry-After header'ı yanıta ekleniyor
✅ Rate limit bilgisi loglanıyor — monitoring için
✅ Load balanced ortamda Redis backed limiter kullanılıyor
✅ UseRateLimiter() sırası doğru — UseAuthentication() sonrası
✅ Health check endpoint'leri DisableRateLimiting ile muaf
✅ Test senaryoları yazıldı
Temel Çıkarımlar
- .NET 8 ile harici paket olmadan production-grade rate limiting mümkün
- Algoritma seçimi senaryoya göre yapılmalı — hepsi için fixed window değil
- Global limiter tüm endpoint'leri korur, named policy spesifik endpoint'leri özelleştirir
- Partition key kritik — IP, kullanıcı ID veya API key bazlı sınırlama farklı davranır
- Load balanced ortamda in-memory limiter yetersizdir — Redis zorunludur
- Retry-After header'ı istemcilerin akıllı davranmasını sağlar
- Rate limit aşımları loglanmalı ve izlenmeli — güvenlik sinyali olabilir
- Health check ve monitoring endpoint'leri her zaman muaf tutulmalıdır
Rate limiting kullanıcıyı cezalandırmaz — sistemi korur. Doğru limitler belirlendiğinde meşru kullanıcılar hiçbir zaman bu sınıra çarpmaz, sadece kötü niyetli veya hatalı istemciler engellenir.