.NET 7 Core Web API Rate Limiting

Photo by Makarios Tang on Unsplash

Merhaba, bu yazımızda .NET Core 7 ile gelen rate limiting kavramını inceleyeceğiz.

Her geliştirdiğimiz ve devreye aldığımız uygulama çalıştığı makine üzerinden ortak kaynakları tüketmektedir. CPU, bellek ve I/O yüklerini paylaşan uygulamalar bunlara ek olarak veri tabanı işlemleri de yapmaktadırlar. Böyle bir durumda uygulamaların hem kaynak tüketimleri hem de yaptıkları işlemlerin sayılarının kontrol altına alınması önemlidir.

Bilerek veya bir hata sebebiyle uygulamalar fazla kaynak tüketebilirler. Oluşturduğunuz bir mobil uygulama API uygulamanıza istenilenden fazla istek gönderebilir. Bu da fazla kaynak tüketimi ile birlikte performans sorunları oluşturacaktır. Oluşturduğumuz uygulamalar çalıştıkları sunucuların kapasitelerine göre belirli sayıda istekleri işleyebilmektedirler. Bu istek sayısının sınırın dışına çıkması veri tabanı ve uygulamanın yavaşlamasına ve hatta hatalara sebep olacaktır.

Bilinçli bir şekilde uygulamaların performanslarını düşürmek amacıyla yapılan saldırılardan korunmak için de rate limit özelliğini kullanabilirsiniz. Bildiğiniz gibi kötü niyetli kullanıcılar uygulamanıza sürekli istek yaparak uygulamanızın kullanılamaz hale gelmesini sağlayabilir. Bu tür saldırılara da DOS (Denial Of Services) adı verilmektedir.

Bir web uygulamasında hız sınırlama genellikle web uygulaması tarafından işlenen istek sayısını sınırlama anlamına gelmektedir. Rate limiting kavramı da bu noktada devreye giriyor. Rate limiting sayesinde uygulamanız için bir zaman aralığında veya bir kullanıcı için istek sayısı belirleme imkanına sahip olursunuz. Örneğin, “5 dakika içinde 5000 istek veya 5 dakika içinde kullanıcı başına 10 istek” şeklinde limitler ekleyebilirsiniz. İstenildiği zaman bir token veya IP adresi üzerinden de isteklerin sınırlandırılması yapılabilmektedir.

Rate Limiting Tipleri

Concurrency limit (Eşzamanlılık sınırı) : Bu tip sınırlamalar bir kaynağa eş zamanı olarak erişilebilecek istek sayısını kontrol etmektedir. İstek sayısı belirlenen sınıra geldiği zaman istekler kesilecek ve hata alacaktır. Gelen ilk istek tamamlandıktan sonra belirlenen sınır 1 artırılacak ve sınırda kalmış olan istek işlenmeye başlayacaktır.

Fixed window limit (Sabit sınır) : Belirlenen bir zaman aralığında, belirlenen sayıda istek yapılmasını sağlar. Her anlatımda karşılaştığımız örnek ile açıklamaya çalışayım. (Ben tiyatro olarak yazacağım 🙂 ) Bir tiyatro salonunda oyunu izleyecek kişi sayısı daha önceden koltuk sayısı ile sabitlenmiştir. Yine aynı şekilde tiyatro oyununun süresi de bilinmektedir. 50 kişilik bir tiyatro salonuna alınan izleyiciler 1 saat boyunca oyunu izleyecekler. Dışarıda ise sonraki seans için yeni izleyiciler sıraya girmekte. Oyun bittikten sonra sıradaki izleyiciler salona girerler ve oyun başlar. Daha sonra yine bir 50 kişi sıraya girer ve bu olay devamlı tekrarlanır. Sabit sınırlı limit kontrolleri de bu şekilde yapılmaktadır.

Sliding window limit (Kayan sınır) : Bu limit tipi bir veri akışındaki verileri belirli bir pencere boyutu içinde (genellikle zaman bazlı) toplayarak, bu pencerenin belirli bir süre boyunca (genellikle saniye veya dakika cinsinden) kaydırıldığı bir algoritmadır. Bir API uygulamasının trafiği izlemek için bir kaydırma penceresi limiti kullanabilirsiniz ve belirli bir süre içinde (örneğin, son 5 dakika içinde) yapılan tüm istekleri sayabilirsiniz.

Örneğin, bir API kullanıcısı her dakika içinde en fazla 100 istek yapabiliyorsa, kaydırma penceresi limiti 1 dakika olarak ayarlanır. Her dakika başında, API kullanıcısının son 1 dakika içinde yaptığı isteklerin sayısı hesaplanır. Bu sayı, 100’ün üzerine çıkarsa API kullanıcısı bir hata kodu alır ve ek istekler reddedilir.

Bu algoritmayı bir çizim ile açıklamaya çalışalım.

2 saatlik bir periyodu 4’e bölerek 30 dakikalık bölümler oluşturduğumuzu düşünelim. Her 30 dakika ayrı bir bölüm olarak değerlendireceğiz. Bu 30 dakikalık bölümleri de 10’ar dakikalık kontrol alanlarına bölüyoruz ve toplam limit olarak da 100 veriyoruz.

İlk 20 dakika hiç istek gelmemiş durumda ve şu an üçüncü 10 dakika içinde bulunuyoruz.

https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2022/07/sliding_part1.png

Bulunduğumuz 10 dakikalık periyod içerisinde 50 istek geliyor. Pencere bir 10 dakika ileriye alınıyor. Şu an halen ilk 30 dakika içerisine olduğumuz için ikinci 30 dakika için aktarılıyor ve son durumda 50 istek hakkı daha aktif durumda.

https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2022/07/sliding_part2.png

Sonraki 10 dakika içerisine pencere kaydırılıyor ve 20 istek daha geliyor. Halen ilk 30 dakikalık bölüm içinde olduğumuzdan dolayı toplam gelen istek sayısı 70 oluyor ve kalan limitimiz 30 olarak güncelleniyor.

https://devblogs.microsoft.com/dotnet/wp-content/uploads/sites/10/2022/07/sliding_part3.png

Ardından pencere bir yana kaydırılıyor ve artık ikinci 30 dakikanın tamamen içinde bulunuyoruz. Elimizde kayıtlı istek sayısı 20 ve ikinci 30 dakikalık bölüm 20 istek ile başlıyor. Kanal limitimiz de 80 olarak devam ediyor.

Genel olarak algoritma bu şekilde devam ederek çalışıyor.

Token Bucket Limit (Jeton Kovası Sınırı) : Bir kovanın içinde jetonlar kadar işlem yapılabilmesi şeklinde kısaltılabilir. Dışardan gelen istek kova içindeki jetonlardan birini alır ve işlem gerçekleşir. Belirli zaman aralıklarında ise kovaya taşmayacak şekilde (kovanın bir limiti mevcut) yeni jetonlar eklenir. Eğer kova boş ise gelen istek reddedilir.

Şimdi de örnek proje ile rate limit konusunu kod üzerinden inceleyelim.

Öncelikle projemizi oluşturalım. BookRateLimit isimli bir Web Api projesi oluşturuyoruz.

Program.cs sınıfımız içinde sınırlandırma işlemleri tanımlayabiliriz. Bunun için builder.Services.AddRateLimiter(…) middleware kayıt işlemini yapmamız yeterli olacaktır. Tanımlamalarımızı 1 dakika içinde en fazla 5 istek kabul edilebilir şeklinde yapacağız.

//Program.cs Sınıfımız
using System.Threading.RateLimiting;

namespace BookRateLimit
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

//Rate Limit Tanımları
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpcontext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpcontext.Request.Headers.Host.ToString(),
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 3,
Window = TimeSpan.FromMinutes(1)
}
));
});


// Add services to the container.

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseAuthorization();

//Tanımladığımız özellikler ile rate limit middleware aktif edilir.
app.UseRateLimiter();

app.MapControllers();

app.Run();
}
}
}

Test öncesinde nasıl bir tanımlama yaptık onu inceleyelim.

Bir GlobalLimiter oluşturarak API’ye gelen tüm istekler için rate limit tanımlarının aktif olmasını sağlıyoruz.

PartitionedRateLimiter ile limit tanımlanmasının istediğimiz kontrollerimizi çeşitlendirebiliyoruz. Örnek olarak farklı istek tipleri için farklı limit tanımları yapabiliyoruz.

Örneğimizde basit olması açısından FixedWindowLimiter kuralını uyguladık.

PermitLimit ile pencere içinde maksimum istek sayısını, Window ile de zaman aralığını belirtiyoruz.

Şimdi testimizi yapabiliriz. Uygulamamızı çalıştırdığımız zaman Swagger otomatik olarak devreye girecek ve metodumuzu çalıştırabileceğiz.

İlk Execute işlemi ile yanıtlarımızı alabiliyoruz. Fakat 1 dakika içinde 5’ten fazla istek gelmesi durumunda API istekleri keserek hata mesajı geri döndürecektir.

Başarılı İstek Cevabı.
Dakikada 5 istek kuralını ihlal ettiğimiz zaman alacağımız hata.

Rate limit özelliğinin aktif olması ile birlikte kurallara uymayan yani limite takılan istekler 503 hatası alarak sonlanıyorlar. Peki belirli bir hata kodu ile kullanıcıları uyarmak istersek?

O zaman yine Program.cs sınıfımız içerisinde değişiklik yaparak OnRejected durumunda nasıl davranılması gerektiğini belirleyeceğiz.

Program.cs sınıfımızı aşağıdaki şekilde güncelliyoruz.

//Program.cs Sınıfımız
using System.Threading.RateLimiting;

namespace BookRateLimit
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

//Rate Limit Tanımları
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpcontext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpcontext.Request.Headers.Host.ToString(),
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 3,
Window = TimeSpan.FromMinutes(1)
}
));

//İsteklerin engellenmesi durumunda verilence hata kodu ve mesaj bilgisi.
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;

if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
await context.HttpContext.Response.WriteAsync(
$"İstek sınır sayısına ulaştınız. {retryAfter.TotalMinutes} dakika sonra tekrar deneyiniz. ", cancellationToken: token);
}
else
{
await context.HttpContext.Response.WriteAsync(
"İstek sınırına ulaştınız. Daha sonra tekrar deneyin. ", cancellationToken: token);
}
};
});


// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseAuthorization();

//Tanımladığımız özellikler ile rate limit middleware aktif edilir.
app.UseRateLimiter();

app.MapControllers();

app.Run();
}
}
}

Uygulamamızı yeniden test edersek bu defa hata kodu olarak 429 – Too Many Request hatasını aldığımızı göreceksiniz.

Kullanıcıların isteklerini kesmek istemediğimiz durumlarda sınır için belirlediğimiz sürenin dolmasını beklemeleri sağlayabiliriz. Böylelikle isteklerin kaybolmasının önüne geçmiş oluruz. Yalnız, burada gelecek istek sayısı fazla ise uygulamamız için büyük bir request queue oluşabilecektir. Bu konuda dikkatli olmak gerekmektedir.

Bu değişiklikler için de Program.cs sınıfımızı aşağıdaki gibi düzenliyoruz.

//Program.cs Sınıfımız
using System.Threading.RateLimiting;

namespace BookRateLimit
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

//Rate Limit Tanımları
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpcontext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpcontext.Request.Headers.Host.ToString(),
factory: partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 3,
Window = TimeSpan.FromMinutes(1),
//Kuyruk bilgileri
QueueLimit = 2,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}
));

options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;

if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
await context.HttpContext.Response.WriteAsync(
$"İstek sınır sayısına ulaştınız. {retryAfter.TotalMinutes} dakika sonra tekrar deneyiniz. ", cancellationToken: token);
}
else
{
await context.HttpContext.Response.WriteAsync(
"İstek sınırına ulaştınız. Daha sonra tekrar deneyin. ", cancellationToken: token);
}
};
});


// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseAuthorization();

//Tanımladığımız özellikler ile rate limit middleware aktif edilir.
app.UseRateLimiter();

app.MapControllers();

app.Run();
}
}
}

İki yeni tanım eklemiş olduk. QueueLimit limit sınırına ulaşıldıktan sonra kaç adet isteğin belirlenen sürenin dolmasını bekleyeceğini, QueueProcessingOrder ise yeni süre başladığı zaman öncelikle hangi isteğin işleyeceğini belirlemektedir. OldestFirst ile de ilk gelenin ilk çıkacağı bir yapı kurmuş oluyoruz.

İstekler hata almadan kuyrukta bekliyor.

Uygulamamızda yaptığımız son değişiklikleri test ettiğimiz zaman sınıra ulaşan istekler beklemeye alınacak ve süre dolduktan sonra işleneceklerdir.

Uygulamamız içinde kullandığımız metotların rate limit bilgilerini farklılaştırabiliriz. Bunun için de policy yazılarak tanımlar yapılabilmektedir. Policy eklemek için Program.cs sınıfımızı yeniden güncelleyelim. GlobalLimiter özelliğini comment ile kapatarak aktif olmamasını sağlıyoruz.

//Program.cs Sınıfımız
using System.Threading.RateLimiting;

namespace BookRateLimit
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

//Rate Limit Tanımları
builder.Services.AddRateLimiter(options =>
{
//options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpcontext =>
//RateLimitPartition.GetFixedWindowLimiter(
// partitionKey: httpcontext.Request.Headers.Host.ToString(),
// factory: partition => new FixedWindowRateLimiterOptions
// {
// AutoReplenishment = true,
// PermitLimit = 3,
// Window = TimeSpan.FromMinutes(1),
// //Kuyruk bilgileri
// QueueLimit = 2,
// QueueProcessingOrder = QueueProcessingOrder.OldestFirst
// }
//));

//Eklenen Policy Bilgileri
options.AddPolicy("User", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(httpContext.Request.Headers.Host.ToString(),
partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
}));

options.AddPolicy("Auth", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(httpContext.Request.Headers.Host.ToString(),
partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1)
}));

options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;

if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
await context.HttpContext.Response.WriteAsync(
$"İstek sınır sayısına ulaştınız. {retryAfter.TotalMinutes} dakika sonra tekrar deneyiniz. ", cancellationToken: token);
}
else
{
await context.HttpContext.Response.WriteAsync(
"İstek sınırına ulaştınız. Daha sonra tekrar deneyin. ", cancellationToken: token);
}
};
});

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseAuthorization();

//Tanımladığımız özellikler ile rate limit middleware aktif edilir.
app.UseRateLimiter();

app.MapControllers();

app.Run();
}
}
}

Şimdi WeatherForecastController.cs için değişikliklerimizi ekleyelim. Burada amacımız her metodun ayrı policy ile çalışmasını sağlamak olacak.

//WeatherForecastController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

namespace BookRateLimit.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

private readonly ILogger<WeatherForecastController> _logger;

public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}

[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}

[EnableRateLimiting("User")]
[HttpGet]
[Route("SayUserHello")]
public string SayUserHello()
{
return "Hello User.";
}

[EnableRateLimiting("Auth")]
[HttpGet]
[Route("LoginUser")]
public string LoginUser()
{
return "User Login Completed.";
}
}
}

Burada yeni eklediğimiz metotlara [EnableRateLimiting(“”)] özellik (attribute) atamasını yapmış olduk. Böylece tanımladığımız poliçelerin (policy) metotlar için ayrı ayrı aktif olmasını sağlayabiliyoruz.

Yazdığımız metotlarda Global olarak tanımlanmış olan bir limit bilgisinin kullanılmamasını isteyebilir. Böyle bir durumda limit kurallarına dahil olmamasını istediğimiz metodumuza [DisableRateLimiting] özelliğini (attribute) eklememiz yeterli olacaktır.

[DisableRateLimiting]                
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
}).ToArray();
}

Son olarak eğer [EnableRateLimiting(“”)] ve [DisableRateLimiting] özellik (attribute )atamalarını yazdığımız Controller için yaparsak controller içerisindeki tüm metotlarımız bu özellik atamasından etkilenecektir.

//WeatherForecastController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

namespace BookRateLimit.Controllers
{
[EnableRateLimiting("User")]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

private readonly ILogger<WeatherForecastController> _logger;

public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}

[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}

[HttpGet]
[Route("SayUserHello")]
public string SayUserHello()
{
return "Hello User.";
}

[HttpGet]
[Route("LoginUser")]
public string LoginUser()
{
return "User Login Completed.";
}
}
}

Bu yazıda genel olarak .NET 7 ile gelen Rate Limit özelliğini anlatmaya çalıştım. Umarım herkes için anlaşılır olmuştur.

Görüşmek üzere.

Oluşturduğumuz uygulama kodlarına aşağıdaki bağlantıdan ulaşabilirsiniz.

https://github.com/onurkarakus/BookRateLimit

Kaynaklar :
https://devblogs.microsoft.com/dotnet/announcing-rate-limiting-for-dotnet/#sliding-window-limit