Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
**Rate-limiting pattern**
**Rate-limiting pattern**

Rate limiting involves restricting the number of requests that a client can make.
A client is identified with an access token, which is used for every request to a resource.
Expand Down
233 changes: 226 additions & 7 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,232 @@
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;
using RateLimiter.Services;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace RateLimiter.Tests;

[TestFixture]
public class RateLimiterTest
public class RateLimiterTests
{
[Test]
public void Example()
{
Assert.That(true, Is.True);
}
}
[Test]
public void FixedWindowRule_ShouldLimitRequests()
{
var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(5));

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed);
}

[Test]
public void SlidingWindowRule_ShouldLimitRequests()
{
var rule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5));

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed);
}

[Test]
public void RateLimiterManager_ShouldApplyMultipleRules()
{
var manager = new RateLimiterManager(new List<ResourceRateLimitConfig>
{
new ResourceRateLimitConfig
{
Resource = "/api/test",
Rules = new List<IRateLimitRule>
{
new FixedWindowRule(2, TimeSpan.FromSeconds(5)),
new SlidingWindowRule(1, TimeSpan.FromSeconds(2))
}
}
});

Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/test").IsAllowed);
Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/test").IsAllowed);
}

[Test]
public void MultipleClients_ShouldBeTrackedIndependently()
{
var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(5));

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client2").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client2").IsAllowed);
}

[Test]
public void MultipleResources_ShouldHaveSeparateLimits()
{
var manager = new RateLimiterManager(new List<ResourceRateLimitConfig>
{
new ResourceRateLimitConfig
{
Resource = "/api/resource1",
Rules = new List<IRateLimitRule> { new FixedWindowRule(2, TimeSpan.FromSeconds(5)) }
},
new ResourceRateLimitConfig
{
Resource = "/api/resource2",
Rules = new List<IRateLimitRule> { new FixedWindowRule(1, TimeSpan.FromSeconds(5)) }
}
});

Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/resource1").IsAllowed);
Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/resource1").IsAllowed);
Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/resource1").IsAllowed);

Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/resource2").IsAllowed);
Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/resource2").IsAllowed);
}

[Test]
public async Task RequestsShouldResetAfterTimeWindow()
{
var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(500));

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed);

await Task.Delay(600);

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
}

[Test]
public void FixedAndSlidingWindowTogether_ShouldApplyStricterRule()
{
var manager = new RateLimiterManager(new List<ResourceRateLimitConfig>
{
new ResourceRateLimitConfig
{
Resource = "/api/strict",
Rules = new List<IRateLimitRule>
{
new FixedWindowRule(3, TimeSpan.FromSeconds(10)),
new SlidingWindowRule(1, TimeSpan.FromSeconds(2))
}
}
});

Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/strict").IsAllowed);
Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/strict").IsAllowed);
}

[Test]
public async Task SlidingWindowRule_ShouldAllowNewRequestsAfterOldExpire()
{
var rule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5));

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed);

await Task.Delay(5100);

Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed);
}

[Test]
public void InsanelyHighRequestVolume_ShouldFailQuickly()
{
var rule = new FixedWindowRule(10, TimeSpan.FromSeconds(1));

for (int i = 0; i < 10; i++)
{
Assert.IsTrue(rule.IsRequestAllowed("crazy_user_1").IsAllowed);
}

Assert.IsFalse(rule.IsRequestAllowed("crazy_user_1").IsAllowed);
}

[Test]
public async Task TimeWindowBoundary_ShouldResetExactlyOnTime()
{
var rule = new FixedWindowRule(2, TimeSpan.FromMilliseconds(500));

Assert.IsTrue(rule.IsRequestAllowed("boundary_user").IsAllowed);
Assert.IsTrue(rule.IsRequestAllowed("boundary_user").IsAllowed);
Assert.IsFalse(rule.IsRequestAllowed("boundary_user").IsAllowed);

await Task.Delay(500);

Assert.IsTrue(rule.IsRequestAllowed("boundary_user").IsAllowed);
}

[Test]
public void MultipleUsersAndIPs_ShouldNotInterfere()
{
var rule = new FixedWindowRule(5, TimeSpan.FromSeconds(1));

var users = new List<string>();
for (int i = 0; i < 1000; i++)
{
users.Add($"User{i}_IP_192.168.1.{i % 255}");
}

foreach (var user in users)
{
Assert.IsTrue(rule.IsRequestAllowed(user).IsAllowed);
}

Assert.IsTrue(rule.IsRequestAllowed("User999_IP_192.168.1.1").IsAllowed);
}

[Test]
public void MultipleRulesConflict_ShouldEnforceStrictestRule()
{
var manager = new RateLimiterManager(new List<ResourceRateLimitConfig>
{
new ResourceRateLimitConfig
{
Resource = "/api/conflict",
Rules = new List<IRateLimitRule>
{
new FixedWindowRule(10, TimeSpan.FromSeconds(10)),
new SlidingWindowRule(2, TimeSpan.FromSeconds(5))
}
}
});

var clientKey = "user1_IP_192.168.1.1:/api/conflict";

Assert.IsTrue(manager.IsRequestAllowed(clientKey, "/api/conflict").IsAllowed);
Assert.IsTrue(manager.IsRequestAllowed(clientKey, "/api/conflict").IsAllowed);

Assert.IsFalse(manager.IsRequestAllowed(clientKey, "/api/conflict").IsAllowed);
}

[Test]
public void RandomizedClientsAndEndpoints_ShouldAllBeTrackedSeparately()
{
var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(5));

var random = new Random();
var clients = new HashSet<string>();

for (int i = 0; i < 500; i++)
{
var user = $"User{random.Next(1, 100)}";
var ip = $"192.168.{random.Next(1, 255)}.{random.Next(1, 255)}";
var endpoint = $"/api/{random.Next(1, 10)}";

var clientKey = $"{user}_{ip}:{endpoint}";
clients.Add(clientKey);

Assert.IsTrue(rule.IsRequestAllowed(clientKey).IsAllowed);
}

Assert.AreEqual(500, clients.Count);
}
}
9 changes: 9 additions & 0 deletions RateLimiter/Models/RateLimitEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;

namespace RateLimiter.Models;

public class RateLimitEntry
{
public int Count { get; set; }
public DateTime ResetTime { get; set; }
}
9 changes: 9 additions & 0 deletions RateLimiter/Models/RateLimitResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;

namespace RateLimiter.Models;

public class RateLimitResult
{
public bool IsAllowed { get; set; }
public TimeSpan RetryAfter { get; set; }
}
10 changes: 10 additions & 0 deletions RateLimiter/Models/ResourceRateLimitConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using RateLimiter.Rules;

namespace RateLimiter.Models;

public class ResourceRateLimitConfig
{
public string? Resource { get; set; }
public List<IRateLimitRule> Rules { get; set; } = new();
}
18 changes: 18 additions & 0 deletions RateLimiter/RateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using RateLimiter.Services;

namespace RateLimiter;

public class RateLimiter
{
private readonly RateLimiterManager _rateLimiterManager;

public RateLimiter(RateLimiterManager rateLimiterManager)
{
_rateLimiterManager = rateLimiterManager;
}

public bool AllowRequest(string clientId, string resource)
{
return _rateLimiterManager.IsRequestAllowed(clientId, resource).IsAllowed;
}
}
35 changes: 35 additions & 0 deletions RateLimiter/RateLimiterManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using RateLimiter.Models;

namespace RateLimiter.Services;

public class RateLimiterManager
{
private readonly Dictionary<string, ResourceRateLimitConfig> _resourceLimits;

public RateLimiterManager(IEnumerable<ResourceRateLimitConfig> resourceLimits)
{
_resourceLimits = resourceLimits.ToDictionary(r => r.Resource ?? string.Empty);
}

public RateLimitResult IsRequestAllowed(string clientId, string resource)
{
if (!_resourceLimits.TryGetValue(resource, out var config))
{
return new RateLimitResult { IsAllowed = false, RetryAfter = TimeSpan.Zero };
}

foreach (var rule in config.Rules)
{
var result = rule.IsRequestAllowed(clientId);
if (!result.IsAllowed)
{
return result;
}
}

return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero };
}
}
41 changes: 41 additions & 0 deletions RateLimiter/Rules/FixedWindowRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using RateLimiter.Models;
using RateLimiter.Rules;

namespace RateLimiter.Rules;

public class FixedWindowRule : IRateLimitRule
{
private readonly int _maxRequests;
private readonly TimeSpan _windowSize;
private readonly Dictionary<string, RateLimitEntry> _clientRequestCounts = new();

public FixedWindowRule(int maxRequests, TimeSpan windowSize)
{
_maxRequests = maxRequests;
_windowSize = windowSize;
}

public RateLimitResult IsRequestAllowed(string clientId)
{
lock (_clientRequestCounts)
{
DateTime now = DateTime.UtcNow;

if (!_clientRequestCounts.TryGetValue(clientId, out var entry) || now >= entry.ResetTime)
{
_clientRequestCounts[clientId] = new RateLimitEntry { Count = 1, ResetTime = now + _windowSize };
return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero };
}

if (entry.Count < _maxRequests)
{
entry.Count++;
return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero };
}

return new RateLimitResult { IsAllowed = false, RetryAfter = entry.ResetTime - now };
}
}
}
8 changes: 8 additions & 0 deletions RateLimiter/Rules/IRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using RateLimiter.Models;

namespace RateLimiter.Rules;

public interface IRateLimitRule
{
RateLimitResult IsRequestAllowed(string clientId);
}
Loading