From 1cc388466edcbd142c95c5eb47269930c69b5c97 Mon Sep 17 00:00:00 2001 From: Nami Sharifzadeh Date: Fri, 21 Mar 2025 17:22:28 -0500 Subject: [PATCH 1/2] init --- RateLimiter.Tests/RateLimiter.Tests.csproj | 15 +-- RateLimiter.Tests/RateLimiterTest.cs | 111 +++++++++++++++++++-- RateLimiter.sln | 20 ++++ RateLimiter/ApiRateLimiter.cs | 31 ++++++ RateLimiter/RateLimiter.csproj | 2 +- RateLimiter/SampleRules.cs | 76 ++++++++++++++ 6 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 RateLimiter/ApiRateLimiter.cs create mode 100644 RateLimiter/SampleRules.cs diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..4efb8e0d 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -1,15 +1,18 @@  - net6.0 - latest + net9.0 + false + enable enable - - - + - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..e69322c6 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,112 @@ -using NUnit.Framework; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.DataCollection; +using NUnit.Framework; +using System; +using System.Linq; +using System.Threading.Tasks; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + private ApiRateLimiter _rateLimiter; + + [SetUp] + public void Setup() + { + _rateLimiter = new ApiRateLimiter(maxRequests: 3, timeWindowSeconds: 30); + } + + [Test] + public void AllowsRequestsWithinLimit() + { + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + } + + [Test] + public void BlocksRequestsOverLimit() + { + // First 3 requests should be allowed + for (int i = 0; i < 3; i++) + { + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + } + + // Fourth request should be blocked + Assert.That(_rateLimiter.IsAllowed("client1"), Is.False); + } + + [Test] + public async Task AllowsRequestsAfterTimeWindowReset() + { + // Use up all requests + for (int i = 0; i < 3; i++) + { + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + } + + // Wait for time window to reset + await Task.Delay(TimeSpan.FromSeconds(30)); + + // Should allow requests again + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + } + + [Test] + public void HandlesMultipleClientsIndependently() + { + // client1 uses all requests + for (int i = 0; i < 3; i++) + { + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + } + Assert.That(_rateLimiter.IsAllowed("client1"), Is.False); + + // client2 should still be allowed + Assert.That(_rateLimiter.IsAllowed("client2"), Is.True); + } + + [Test] + public void ZeroRequestsConfiguration() + { + var zeroLimiter = new ApiRateLimiter(maxRequests: 0, timeWindowSeconds: 1); + Assert.That(zeroLimiter.IsAllowed("client1"), Is.False); + } + + [Test] + public async Task ParallelRequests() + { + var tasks = new Task[5]; + for (int i = 0; i < 5; i++) + { + tasks[i] = Task.Run(() => _rateLimiter.IsAllowed("client1")); + } + + var results = await Task.WhenAll(tasks); + Assert.That(results.Count(r => r), Is.EqualTo(3)); + Assert.That(results.Count(r => !r), Is.EqualTo(2)); + } + + [Test] + public void RequestMultipleRules() + { + _rateLimiter.AddRule(SampleRules.CreateDailyQuotaRule(2)); + + // First request is allowed + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + + // Second request should be blocked + Assert.That(_rateLimiter.IsAllowed("client1"), Is.False); + } + [Test] + public void RequestWithNonDefaultRule() + { + _rateLimiter.RemoveAll(); + _rateLimiter.AddRule(SampleRules.CreateDailyQuotaRule(2)); + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); + Assert.That(_rateLimiter.IsAllowed("client1"), Is.False); + } } \ No newline at end of file diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..a59a88bd 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -15,17 +15,37 @@ EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|x64.ActiveCfg = Debug|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|x64.Build.0 = Debug|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|x86.ActiveCfg = Debug|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|x86.Build.0 = Debug|Any CPU {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.ActiveCfg = Release|Any CPU {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.Build.0 = Release|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|x64.ActiveCfg = Release|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|x64.Build.0 = Release|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|x86.ActiveCfg = Release|Any CPU + {36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|x86.Build.0 = Release|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|x64.Build.0 = Debug|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|x86.ActiveCfg = Debug|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|x86.Build.0 = Debug|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|x64.ActiveCfg = Release|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|x64.Build.0 = Release|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|x86.ActiveCfg = Release|Any CPU + {C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/RateLimiter/ApiRateLimiter.cs b/RateLimiter/ApiRateLimiter.cs new file mode 100644 index 00000000..9850b162 --- /dev/null +++ b/RateLimiter/ApiRateLimiter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter +{ + public class ApiRateLimiter + { + private readonly List _rules = new List(); + + public ApiRateLimiter(int maxRequests, int timeWindowSeconds) + { + AddRule(SampleRules.CreateTimeWindowRule(maxRequests, TimeSpan.FromSeconds(timeWindowSeconds))); + } + + public void AddRule(RateLimitRule rule) + { + _rules.Add(rule); + } + public void RemoveAll() + { + _rules.Clear(); + } + + public bool IsAllowed(string clientId) + { + var timestamp = DateTime.UtcNow; + return _rules.All(rule => rule(clientId, timestamp)); + } + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..b4648de7 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -1,6 +1,6 @@  - net6.0 + net9.0 latest enable diff --git a/RateLimiter/SampleRules.cs b/RateLimiter/SampleRules.cs new file mode 100644 index 00000000..ac4b4b9f --- /dev/null +++ b/RateLimiter/SampleRules.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace RateLimiter +{ + public delegate bool RateLimitRule(string clientId, DateTime timestamp); + + public static class SampleRules + { + private static readonly ConcurrentDictionary _clients = + new ConcurrentDictionary(); + + public static RateLimitRule CreateTimeWindowRule(int maxRequests, TimeSpan timeWindow) + { + return (clientId, timestamp) => + { + var clientInfo = _clients.GetOrAdd(clientId, _ => new ClientRequestInfo()); + + lock (clientInfo.Lock) + { + // Remove expired requests + clientInfo.RemoveExpiredRequests(timestamp, timeWindow); + + // Check if within limit + if (clientInfo.RequestTimestamps.Count < maxRequests) + { + clientInfo.RequestTimestamps.Add(timestamp); + return true; + } + + return false; + } + }; + } + + public static RateLimitRule CreateDailyQuotaRule(int maxDailyRequests) + { + return (clientId, timestamp) => + { + var clientInfo = _clients.GetOrAdd(clientId, _ => new ClientRequestInfo()); + + lock (clientInfo.Lock) + { + var startOfDay = timestamp.Date; + var requestsToday = clientInfo.RequestTimestamps.Where(t => t.Date == startOfDay).Count(); + + if (requestsToday < maxDailyRequests) + { + clientInfo.RequestTimestamps.Add(timestamp); + return true; + } + + return false; + } + }; + } + } + + internal class ClientRequestInfo + { + public List RequestTimestamps { get; } = []; + public object Lock { get; } = new object(); + + public void RemoveExpiredRequests(DateTime timestamp, TimeSpan timeWindow) + { + while (RequestTimestamps.Count > 0 && timestamp - RequestTimestamps[0] > timeWindow) + { + RequestTimestamps.RemoveAt(0); + } + } + + } +} \ No newline at end of file From ddb04596b36e3e1a1a073628726fad8ad5efad08 Mon Sep 17 00:00:00 2001 From: Nami Sharifzadeh Date: Fri, 21 Mar 2025 18:37:45 -0500 Subject: [PATCH 2/2] added third rule --- RateLimiter.Tests/RateLimiterTest.cs | 15 ++++++++++++++- RateLimiter/ApiRateLimiter.cs | 8 ++++++++ RateLimiter/SampleRules.cs | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index e69322c6..e7f643ea 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -108,5 +108,18 @@ public void RequestWithNonDefaultRule() Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); Assert.That(_rateLimiter.IsAllowed("client1"), Is.True); Assert.That(_rateLimiter.IsAllowed("client1"), Is.False); - } + } + [Test] + public async Task RequestCertainTimeHasPassedAsync() + { + ApiRateLimiter _timeHasPassed = new ApiRateLimiter(TimeSpan.FromSeconds(30)); + Assert.That(_timeHasPassed.IsAllowed("client1"), Is.True); + Assert.That(_timeHasPassed.IsAllowed("client1"), Is.False); + + await Task.Delay(TimeSpan.FromSeconds(30)); + + // 30 seconds must pass before the next request is allpwed + Assert.That(_timeHasPassed.IsAllowed("client1"), Is.True); + + } } \ No newline at end of file diff --git a/RateLimiter/ApiRateLimiter.cs b/RateLimiter/ApiRateLimiter.cs index 9850b162..5a1567df 100644 --- a/RateLimiter/ApiRateLimiter.cs +++ b/RateLimiter/ApiRateLimiter.cs @@ -12,6 +12,14 @@ public ApiRateLimiter(int maxRequests, int timeWindowSeconds) { AddRule(SampleRules.CreateTimeWindowRule(maxRequests, TimeSpan.FromSeconds(timeWindowSeconds))); } + public ApiRateLimiter(TimeSpan hasPassed) + { + AddRule(SampleRules.CreateCertainTimespanPassed(hasPassed)); + } + public ApiRateLimiter(int maxCallsinADay) + { + AddRule(SampleRules.CreateDailyQuotaRule(maxCallsinADay)); + } public void AddRule(RateLimitRule rule) { diff --git a/RateLimiter/SampleRules.cs b/RateLimiter/SampleRules.cs index ac4b4b9f..6a9b0f65 100644 --- a/RateLimiter/SampleRules.cs +++ b/RateLimiter/SampleRules.cs @@ -53,6 +53,25 @@ public static RateLimitRule CreateDailyQuotaRule(int maxDailyRequests) return true; } + return false; + } + }; + } + public static RateLimitRule CreateCertainTimespanPassed(TimeSpan timeWindow) + { + return (clientId, timestamp) => + { + var clientInfo = _clients.GetOrAdd(clientId, _ => new ClientRequestInfo()); + + lock (clientInfo.Lock) + { + // Remove expired requests + if (clientInfo.RequestTimestamps.Count <= 0 || timestamp - clientInfo.RequestTimestamps.Last().ToUniversalTime() >= timeWindow) + { + clientInfo.RequestTimestamps.Add(timestamp); + return true; + } + return false; } };