API Rate Limiting in .NET¶
Problem Statement¶
When working with APIs that have rate limits, you need to throttle your requests to avoid exceeding these restrictions. Common scenarios include: - APIs limited to 1 request per second - APIs with complex limits like 15 requests per 5 minutes - Batch processing operations that would naturally exceed rate limits
Solution Overview¶
There are three main approaches to implementing rate limiting in .NET applications: 1. Simple Rate Limiter - Basic time-based throttling 2. Built-in RateLimiter (.NET 7+) - Using System.Threading.RateLimiting 3. Sliding Window / Token Bucket - Advanced patterns for complex scenarios
Approach 1: Simple Rate Limiter (1 Request Per Second)¶
Implementation¶
This approach uses SemaphoreSlim to ensure only one request executes at a time, with a minimum delay between requests.
public class SimpleRateLimiter
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly TimeSpan _minInterval = TimeSpan.FromSeconds(1);
private DateTime _lastRequest = DateTime.MinValue;
public async Task<T> ExecuteAsync<T>(Func<Task<T>> apiCall)
{
await _semaphore.WaitAsync();
try
{
var timeSinceLastRequest = DateTime.UtcNow - _lastRequest;
if (timeSinceLastRequest < _minInterval)
{
await Task.Delay(_minInterval - timeSinceLastRequest);
}
_lastRequest = DateTime.UtcNow;
return await apiCall();
}
finally
{
_semaphore.Release();
}
}
}
Usage Example¶
var rateLimiter = new SimpleRateLimiter();
var results = new List<Data>();
foreach (var item in batchItems)
{
var result = await rateLimiter.ExecuteAsync(async () =>
await apiClient.GetDataAsync(item));
results.Add(result);
}
Key Features¶
- Thread-safe using SemaphoreSlim
- Guarantees minimum time between requests
- Simple and easy to understand
- Good for straightforward rate limits (X requests per Y seconds)
Approach 2: Built-in FixedWindowRateLimiter (.NET 7+)¶
Implementation¶
.NET 7 introduced System.Threading.RateLimiting with built-in rate limiting primitives.
using System.Threading.RateLimiting;
var rateLimiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromSeconds(1),
PermitLimit = 1,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 100
});
foreach (var item in batchItems)
{
using var lease = await rateLimiter.AcquireAsync();
if (lease.IsAcquired)
{
var result = await apiClient.GetDataAsync(item);
results.Add(result);
}
}
Key Features¶
- Built-in to .NET 7+
- Supports queuing of requests
- Production-ready and well-tested
- Fixed time windows
Approach 3: Advanced Rate Limiting (15 Requests Per 5 Minutes)¶
For more complex rate limits, you need to track request history over a sliding time window.
Sliding Window Rate Limiter¶
This is the most accurate approach for complex rate limits, tracking actual timestamps of requests.
public class SlidingWindowRateLimiter
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly Queue<DateTime> _requestTimestamps = new();
private readonly int _maxRequests;
private readonly TimeSpan _timeWindow;
public SlidingWindowRateLimiter(int maxRequests, TimeSpan timeWindow)
{
_maxRequests = maxRequests;
_timeWindow = timeWindow;
}
public async Task<T> ExecuteAsync<T>(Func<Task<T>> apiCall)
{
await _semaphore.WaitAsync();
try
{
var now = DateTime.UtcNow;
// Remove timestamps outside the window
while (_requestTimestamps.Count > 0 &&
now - _requestTimestamps.Peek() > _timeWindow)
{
_requestTimestamps.Dequeue();
}
// Wait if we've hit the limit
while (_requestTimestamps.Count >= _maxRequests)
{
var oldestRequest = _requestTimestamps.Peek();
var waitTime = _timeWindow - (now - oldestRequest);
_semaphore.Release();
await Task.Delay(waitTime + TimeSpan.FromMilliseconds(100));
await _semaphore.WaitAsync();
now = DateTime.UtcNow;
// Clean up old timestamps again
while (_requestTimestamps.Count > 0 &&
now - _requestTimestamps.Peek() > _timeWindow)
{
_requestTimestamps.Dequeue();
}
}
// Make the request
_requestTimestamps.Enqueue(now);
return await apiCall();
}
finally
{
_semaphore.Release();
}
}
}
Usage Example¶
var rateLimiter = new SlidingWindowRateLimiter(
maxRequests: 15,
timeWindow: TimeSpan.FromMinutes(5));
foreach (var item in batchItems)
{
var result = await rateLimiter.ExecuteAsync(async () =>
await apiClient.GetDataAsync(item));
results.Add(result);
}
How It Works¶
- Maintains a queue of timestamps for all recent requests
- Before each request, removes timestamps older than the time window
- If the limit is reached, calculates how long to wait before the oldest request expires
- Automatically waits and retries until a slot becomes available
Advantages¶
- Most accurate - no edge cases at window boundaries
- Matches how most APIs enforce rate limits
- Automatically handles waiting and retry logic
- Thread-safe
Approach 4: Token Bucket Pattern¶
For smooth distribution of requests over time rather than bursts.
public class TokenBucketRateLimiter
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly int _bucketCapacity;
private readonly TimeSpan _refillInterval;
private double _tokens;
private DateTime _lastRefill;
public TokenBucketRateLimiter(int maxRequests, TimeSpan timeWindow)
{
_bucketCapacity = maxRequests;
_refillInterval = TimeSpan.FromTicks(timeWindow.Ticks / maxRequests);
_tokens = maxRequests;
_lastRefill = DateTime.UtcNow;
}
public async Task<T> ExecuteAsync<T>(Func<Task<T>> apiCall)
{
await _semaphore.WaitAsync();
try
{
RefillTokens();
while (_tokens < 1)
{
var waitTime = _refillInterval - (DateTime.UtcNow - _lastRefill);
if (waitTime > TimeSpan.Zero)
{
_semaphore.Release();
await Task.Delay(waitTime);
await _semaphore.WaitAsync();
}
RefillTokens();
}
_tokens--;
return await apiCall();
}
finally
{
_semaphore.Release();
}
}
private void RefillTokens()
{
var now = DateTime.UtcNow;
var timePassed = now - _lastRefill;
var tokensToAdd = timePassed.Ticks / (double)_refillInterval.Ticks;
if (tokensToAdd >= 1)
{
_tokens = Math.Min(_bucketCapacity, _tokens + tokensToAdd);
_lastRefill = now;
}
}
}
Usage Example¶
var rateLimiter = new TokenBucketRateLimiter(15, TimeSpan.FromMinutes(5));
// Distributes requests evenly: 1 request every 20 seconds
Key Features¶
- Smooth distribution of requests (15 requests over 5 minutes = 1 request every 20 seconds)
- Allows for some bursting if tokens have accumulated
- Good for APIs that prefer steady traffic over bursts
Comparison and Recommendations¶
| Scenario | Recommended Approach | Reason |
|---|---|---|
| 1 request per second | Simple Rate Limiter | Straightforward, easy to implement |
| .NET 7+ projects | Built-in FixedWindowRateLimiter | Production-ready, well-tested |
| 15 requests per 5 minutes | Sliding Window | Most accurate, prevents edge cases |
| Smooth distribution needed | Token Bucket | Spreads load evenly over time |
| Production applications | Polly + Rate Limiting | Enterprise-grade with retry policies |
Best Practices¶
- Always use UTC times - Avoid timezone issues with
DateTime.UtcNow - Add small buffers - Add 100ms buffer to wait times to account for timing precision
- Make rate limiters reusable - Create as singletons or inject via DI
- Handle rejections gracefully - Have fallback logic when rate limits are hit
- Log rate limit events - Track when throttling occurs for monitoring
- Consider API burst allowances - Some APIs allow short bursts above the base rate
Integration with Dependency Injection¶
// In Program.cs or Startup.cs
services.AddSingleton<IRateLimiter>(sp =>
new SlidingWindowRateLimiter(15, TimeSpan.FromMinutes(5)));
// In your service
public class ApiService
{
private readonly IRateLimiter _rateLimiter;
private readonly HttpClient _httpClient;
public ApiService(IRateLimiter rateLimiter, HttpClient httpClient)
{
_rateLimiter = rateLimiter;
_httpClient = httpClient;
}
public async Task<Data> GetDataAsync(string id)
{
return await _rateLimiter.ExecuteAsync(async () =>
{
var response = await _httpClient.GetAsync($"/api/data/{id}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Data>();
});
}
}
Additional Resources¶
Created: {{date}} Tags: #dotnet #csharp #api #rate-limiting #performance