Skip to content
Closed
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
15 changes: 9 additions & 6 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
</ItemGroup>
</Project>

<ItemGroup>
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</ItemGroup>
</Project>
124 changes: 118 additions & 6 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -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<bool>[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);

}
}
20 changes: 20 additions & 0 deletions RateLimiter.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions RateLimiter/ApiRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace RateLimiter
{
public class ApiRateLimiter
{
private readonly List<RateLimitRule> _rules = new List<RateLimitRule>();

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));
}
}
}
2 changes: 1 addition & 1 deletion RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
Expand Down
95 changes: 95 additions & 0 deletions RateLimiter/SampleRules.cs
Original file line number Diff line number Diff line change
@@ -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<string, ClientRequestInfo> _clients =
new ConcurrentDictionary<string, ClientRequestInfo>();

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<DateTime> 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);
}
}

}
}