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..e7f643ea 100644
--- a/RateLimiter.Tests/RateLimiterTest.cs
+++ b/RateLimiter.Tests/RateLimiterTest.cs
@@ -1,13 +1,125 @@
-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);
+ }
+ [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.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..5a1567df
--- /dev/null
+++ b/RateLimiter/ApiRateLimiter.cs
@@ -0,0 +1,39 @@
+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 ApiRateLimiter(TimeSpan hasPassed)
+ {
+ AddRule(SampleRules.CreateCertainTimespanPassed(hasPassed));
+ }
+ public ApiRateLimiter(int maxCallsinADay)
+ {
+ AddRule(SampleRules.CreateDailyQuotaRule(maxCallsinADay));
+ }
+
+ 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..6a9b0f65
--- /dev/null
+++ b/RateLimiter/SampleRules.cs
@@ -0,0 +1,95 @@
+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;
+ }
+ };
+ }
+ 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;
+ }
+ };
+ }
+ }
+
+ 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