diff --git a/README.md b/README.md index 47e73daa..083d43fa 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..c36214b2 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -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); - } -} \ No newline at end of file + [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 + { + new ResourceRateLimitConfig + { + Resource = "/api/test", + Rules = new List + { + 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 + { + new ResourceRateLimitConfig + { + Resource = "/api/resource1", + Rules = new List { new FixedWindowRule(2, TimeSpan.FromSeconds(5)) } + }, + new ResourceRateLimitConfig + { + Resource = "/api/resource2", + Rules = new List { 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 + { + new ResourceRateLimitConfig + { + Resource = "/api/strict", + Rules = new List + { + 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(); + 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 + { + new ResourceRateLimitConfig + { + Resource = "/api/conflict", + Rules = new List + { + 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(); + + 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); + } +} diff --git a/RateLimiter/Models/RateLimitEntry.cs b/RateLimiter/Models/RateLimitEntry.cs new file mode 100644 index 00000000..f82837e4 --- /dev/null +++ b/RateLimiter/Models/RateLimitEntry.cs @@ -0,0 +1,9 @@ +using System; + +namespace RateLimiter.Models; + +public class RateLimitEntry +{ + public int Count { get; set; } + public DateTime ResetTime { get; set; } +} diff --git a/RateLimiter/Models/RateLimitResult.cs b/RateLimiter/Models/RateLimitResult.cs new file mode 100644 index 00000000..7dd1eec1 --- /dev/null +++ b/RateLimiter/Models/RateLimitResult.cs @@ -0,0 +1,9 @@ +using System; + +namespace RateLimiter.Models; + +public class RateLimitResult +{ + public bool IsAllowed { get; set; } + public TimeSpan RetryAfter { get; set; } +} diff --git a/RateLimiter/Models/ResourceRateLimitConfig.cs b/RateLimiter/Models/ResourceRateLimitConfig.cs new file mode 100644 index 00000000..a0cfe825 --- /dev/null +++ b/RateLimiter/Models/ResourceRateLimitConfig.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using RateLimiter.Rules; + +namespace RateLimiter.Models; + +public class ResourceRateLimitConfig +{ + public string? Resource { get; set; } + public List Rules { get; set; } = new(); +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..8a6ae24e --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -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; + } +} diff --git a/RateLimiter/RateLimiterManager.cs b/RateLimiter/RateLimiterManager.cs new file mode 100644 index 00000000..68493266 --- /dev/null +++ b/RateLimiter/RateLimiterManager.cs @@ -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 _resourceLimits; + + public RateLimiterManager(IEnumerable 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 }; + } +} diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..066106c5 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -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 _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 }; + } + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..4b1554cd --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,8 @@ +using RateLimiter.Models; + +namespace RateLimiter.Rules; + +public interface IRateLimitRule +{ + RateLimitResult IsRequestAllowed(string clientId); +} diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs new file mode 100644 index 00000000..6453554c --- /dev/null +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Models; +using RateLimiter.Rules; + +namespace RateLimiter.Rules; + +public class SlidingWindowRule : IRateLimitRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _windowSize; + private readonly Dictionary> _clientRequests = new(); + + public SlidingWindowRule(int maxRequests, TimeSpan windowSize) + { + _maxRequests = maxRequests; + _windowSize = windowSize; + } + + public RateLimitResult IsRequestAllowed(string clientId) + { + lock (_clientRequests) + { + DateTime now = DateTime.UtcNow; + _clientRequests.TryGetValue(clientId, out var timestamps); + + timestamps ??= new List(); + timestamps.RemoveAll(t => now - t > _windowSize); + + if (timestamps.Count < _maxRequests) + { + timestamps.Add(now); + _clientRequests[clientId] = timestamps; + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } + + return new RateLimitResult { IsAllowed = false, RetryAfter = timestamps.First() + _windowSize - now }; + } + } +}