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