Skip to content

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

  1. Maintains a queue of timestamps for all recent requests
  2. Before each request, removes timestamps older than the time window
  3. If the limit is reached, calculates how long to wait before the oldest request expires
  4. 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

  1. Always use UTC times - Avoid timezone issues with DateTime.UtcNow
  2. Add small buffers - Add 100ms buffer to wait times to account for timing precision
  3. Make rate limiters reusable - Create as singletons or inject via DI
  4. Handle rejections gracefully - Have fallback logic when rate limits are hit
  5. Log rate limit events - Track when throttling occurs for monitoring
  6. 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