Skip to content
Open
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
26 changes: 10 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -8,20 +8,14 @@ The client makes an API call to a particular resource; the server checks whether
If the request is within the limit, then the request goes through.
Otherwise, the API call is restricted.

Some examples of request-limiting rules (you could imagine any others)
* X requests per timespan;
* a certain timespan has passed since the last call;
* For US-based tokens, we use X requests per timespan; for EU-based tokens, a certain timespan has passed since the last call.
**To run the examples:**
run `dotnet run` in RateLimiter

The goal is to design a class(-es) that manages each API resource's rate limits by a set of provided *configurable and extendable* rules. For example, for one resource, you could configure the limiter to use Rule A; for another one - Rule B; for a third one - both A + B, etc. Any combination of rules should be possible; keep this fact in mind when designing the classes.
Working example URLs:
- https://localhost:5001/api/test (token bucket rule)
- https://localhost:5001/api/orders (fixed window rule)
- https://localhost:5001/api/orders/item (region based rule)
- https://localhost:5001/api/users (composite rule)

We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm. There is no need to use a database (in-memory storage is fine) or any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough.

There is a Test Project set up for you to use. However, you are welcome to create your own test project and use whatever test runner you like.

You are welcome to ask any questions regarding the requirements—treat us as product owners, analysts, or whoever knows the business.
If you have any questions or concerns, please submit them as a [GitHub issue](https://github.com/crexi-dev/rate-limiter/issues).

You should [fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) the project and [create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) named as `FirstName LastName` once you are finished.

Good luck!
**To run tests:**
run `dotnet test` in RateLimiter.Tests
4 changes: 2 additions & 2 deletions RateLimiter.Tests/RateLimiter.Tests.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>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
Expand All @@ -12,4 +12,4 @@
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
</ItemGroup>
</Project>
</Project>
70 changes: 60 additions & 10 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,63 @@
using NUnit.Framework;
using System;
using NUnit.Framework;
using RateLimiter.Core;
using RateLimiter.Core.Rules;

namespace RateLimiter.Tests;

[TestFixture]
public class RateLimiterTest
namespace RateLimiter.Tests
{
[Test]
public void Example()
{
Assert.That(true, Is.True);
}
[TestFixture]
public class RateLimiterTest
{
[Test]
public void TestNoRulesConfigured()
{
var rateLimiter = new Core.RateLimiter();
string clientToken = "test-client";
string resourceId = "api/no-rule";

// should allow all requests when no rule is configured
for (int i = 0; i < 12; i++)
{
Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True,
$"Request {i+1} should be allowed when no rule is configured");
}
}

[Test]
public void TestUsesCorrectRule()
{
var rateLimiter = new Core.RateLimiter();
var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(10));

rateLimiter.ConfigureResource("api/test", rule);

string clientToken = "test-client";
string resourceId = "api/test";

Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "Second request should be allowed");
Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.False, "Third request should be denied");
}

[Test]
public void TestMultipleResources()
{
var rateLimiter = new Core.RateLimiter();

// configure different rules for different resources
rateLimiter.ConfigureResource("api/resource1", new FixedWindowRule(1, TimeSpan.FromSeconds(10)));
rateLimiter.ConfigureResource("api/resource2", new FixedWindowRule(2, TimeSpan.FromSeconds(10)));

string clientToken = "test-client";

// should allow 1 request
Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.True, "First request to resource1 should be allowed");
Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.False, "Second request to resource1 should be denied");

// should allow 2 requests
Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "First request to resource2 should be allowed");
Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "Second request to resource2 should be allowed");
Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.False, "Third request to resource2 should be denied");
}
}
}
41 changes: 41 additions & 0 deletions RateLimiter.Tests/Rules/CompositeRuleTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using NUnit.Framework;
using RateLimiter.Core.Rules;

namespace RateLimiter.Tests
{
[TestFixture]
public class CompositeRuleTest
{
[Test]
public void TestCompositeAnd()
{
var rule1 = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); // 2 requests max
var rule2 = new FixedWindowRule(3, TimeSpan.FromSeconds(10)); // 3 requests max
var compositeRule = new CompositeAndRule(rule1, rule2);

string clientToken = "test-client";
string resourceId = "api/test";

Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed by both rules");
Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed by both rules");
Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.False, "Third request should be denied by rule1");
}

[Test]
public void TestCompositeOr()
{
var rule1 = new FixedWindowRule(1, TimeSpan.FromSeconds(10)); // 1 request max
var rule2 = new TokenBucketRule(2, 0); // allow 2 requests
var compositeRule = new CompositeOrRule(rule1, rule2);

string clientToken = "test-client";
string resourceId = "api/test";

Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed by both rules");
Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed by rule2");
Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed by 1 rule");
Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied by both rules");
}
}
}
43 changes: 43 additions & 0 deletions RateLimiter.Tests/Rules/FixedWindowTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Threading;
using NUnit.Framework;
using RateLimiter.Core.Rules;

namespace RateLimiter.Tests
{
[TestFixture]
public class FixedWindowTest
{
[Test]
public void TestAllowAndDeny()
{
// create a fixed window rule with max 3 requests per 10-second window
var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(10));
string clientToken = "test-client";
string resourceId = "api/test";

// should allow exactly 3 requests
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied");
}

[Test]
public void TestWindowReset()
{
// create a fixed window rule with max 1 request per 1 sec window
var rule = new FixedWindowRule(1, TimeSpan.FromSeconds(1));
string clientToken = "test-client";
string resourceId = "api/test";

Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Second request in same window should be denied");

// wait for token to reset
Thread.Sleep(1100);

Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Request in new window should be allowed");
}
}
}
43 changes: 43 additions & 0 deletions RateLimiter.Tests/Rules/RegionBasedTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using RateLimiter.Core.Rules;

namespace RateLimiter.Tests
{
[TestFixture]
public class RegionBasedRuleTest
{
private string GetRegion(string token)
{
if (token.StartsWith("EU"))
return "EU";
return "US";
}

[Test]
public void TestApplyCorrectRuleForRegion()
{
// US rule allows 2 requests, EU rule allows 1 request
var usRule = new FixedWindowRule(2, TimeSpan.FromSeconds(10));
var euRule = new FixedWindowRule(1, TimeSpan.FromSeconds(10));

var regionRules = new Dictionary<string, IRateLimitRule>
{
{ "US", usRule },
{ "EU", euRule }
};

var rule = new RegionBasedRule(regionRules, GetRegion);

// US client should get 2 requests
Assert.That(rule.IsAllowed("US-client", "api/test"), Is.True, "First US request should be allowed");
Assert.That(rule.IsAllowed("US-client", "api/test"), Is.True, "Second US request should be allowed");
Assert.That(rule.IsAllowed("US-client", "api/test"), Is.False, "Third US request should be denied");

// EU client should get 1 request
Assert.That(rule.IsAllowed("EU-client", "api/test"), Is.True, "First EU request should be allowed");
Assert.That(rule.IsAllowed("EU-client", "api/test"), Is.False, "Second EU request should be denied");
}
}
}
43 changes: 43 additions & 0 deletions RateLimiter.Tests/Rules/TokenBucketTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Threading;
using NUnit.Framework;
using RateLimiter.Core.Rules;

namespace RateLimiter.Tests
{
[TestFixture]
public class TokenBucketTest
{
[Test]
public void TestTokenBucketAllowAndDeny()
{
// create a token bucket with 3 tokens and no refill
var rule = new TokenBucketRule(3, 0);
string clientToken = "test-client";
string resourceId = "api/test";

// should allow exactly 3 requests
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied");
}

[Test]
public void TestTokenBucketWithRefill()
{
// create a token bucket with 1 token that refills at 1 token per second
var rule = new TokenBucketRule(1, 1.0);
string clientToken = "test-client";
string resourceId = "api/test";

Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed");
Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Request before refill should be denied");

// wait for token to refill
Thread.Sleep(1100);

Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Request after refill should be allowed");
}
}
}
60 changes: 60 additions & 0 deletions RateLimiter/Builders/RuleBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using RateLimiter.Core.Rules;

namespace RateLimiter.Builders
{
public static class RuleBuilder
{
public static IRateLimitRule CreateFixedWindowRule(int maxRequests, TimeSpan window)
{
return new FixedWindowRule(maxRequests, window);
}

public static IRateLimitRule CreateTokenBucketRule(int capacity, double refillRate)
{
return new TokenBucketRule(capacity, refillRate);
}

public static IRateLimitRule CreateRegionBasedRule(Dictionary<string, IRateLimitRule> regionRules, Func<string, string> regionExtractor, IRateLimitRule? defaultRule = null)
{
return new RegionBasedRule(regionRules, regionExtractor, defaultRule);
}

public static IRateLimitRule CreateCompositeAndRule(params IRateLimitRule[] rules)
{
return new CompositeAndRule(rules);
}

public static IRateLimitRule CreateCompositeOrRule(params IRateLimitRule[] rules)
{
return new CompositeOrRule(rules);
}

public static IRateLimitRule CreateRegionRules()
{
// set region rules
var usRule = new FixedWindowRule(5, TimeSpan.FromMinutes(1));
var euRule = new TokenBucketRule(3, 3/60.0);

// create region mapping
var regionRules = new Dictionary<string, IRateLimitRule>
{
{ "US", usRule },
{ "EU", euRule }
};

// create region rules
return CreateRegionBasedRule(regionRules, GetRegion);
}

private static string GetRegion(string token)
{
if (token.StartsWith("EU"))
{
return "EU";
}
return "US";
}
}
}
30 changes: 30 additions & 0 deletions RateLimiter/Controllers/OrdersController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using System;

namespace RateLimiter.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Ok(new
{
message = "Orders API request successful!!",
timestamp = DateTime.UtcNow
});
}

[HttpGet("item")]
public IActionResult GetItem()
{
return Ok(new
{
message = "Orders item API request successful!!",
timestamp = DateTime.UtcNow
});
}
}
}
Loading