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
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"dotnet.preview.enableSupportForSlnx": false
}
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
**Rate-limiting pattern**

Rate limiting involves restricting the number of requests that can be made by a client.
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.
To prevent abuse of the server, APIs enforce rate-limiting techniques.
Based on the client, the rate-limiting application can decide whether to allow the request to go through or not.
The rate-limiting application can decide whether to allow the request based on the client.
The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit.
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 passed since the last call;
* for US-based tokens, we use X requests per timespan, for EU-based - certain timespan passed since the last call.
* 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.

The goal is to design a class(-es) that manage rate limits for every provided API resource 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 combinations of rules should be possible, keep this fact in mind when designing the classes.
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.

We're more interested in the design itself than in some smart and tricky rate limiting algorithm. There is no need to use neither database (in-memory storage is fine) nor any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough.
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. You are welcome to create your own test project and use whatever test runner you would like.
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/whoever who knows the business.
Should you have any questions or concerns, submit them as a [GitHub issue](https://github.com/crexi-dev/rate-limiter/issues).
You are welcome to ask any questions regarding the requirementstreat 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) once you are finished.
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!
105 changes: 105 additions & 0 deletions RateLimiter.Tests/IntegrationTests/CustomWebApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using RateLimiter.Common.Abstractions;
using RateLimiter.Common.Abstractions.Counters;
using RateLimiter.Common.Abstractions.Rules;
using RateLimiter.Core.Configuration;
using RateLimiter.Core.Services;
using RateLimiter.Core.Services.KeyBuilders;
using RateLimiter.Infrastructure.Counters;

namespace RateLimiter.IntegrationTests;

public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");

// Add logging for tests
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.AddDebug();
});

builder.ConfigureServices(services =>
{
// Replace rate limit counter with a new instance to ensure clean state between tests
RemoveAllServiceRegistrationsOf<IRateLimitCounter>(services);
RemoveAllServiceRegistrationsOf<IMemoryCache>(services);
RemoveAllServiceRegistrationsOf<IRateLimiterService>(services);
RemoveAllServiceRegistrationsOf<IRateLimitRuleProvider>(services);
RemoveAllServiceRegistrationsOf<IKeyBuilder>(services);
RemoveAllServiceRegistrationsOf<IRateLimitClientIdentifierProvider>(services);

// Add a memory cache for testing
services.AddMemoryCache();

// Configure rate limit options
services.Configure<RateLimitOptions>(options =>
{
options.EnableRateLimiting = true;
options.IncludeHeaders = true;
options.HeaderPrefix = "X-RateLimit";
options.StatusCode = 429;
options.ClientIdHeaderName = "X-ClientId";
options.RegionHeaderName = "X-Region";
});

// Add key builder
services.AddSingleton<IKeyBuilder, ResourceKeyBuilder>();

// Add a memory counter with a singleton lifetime for testing
services.AddSingleton<IRateLimitCounter, MemoryRateLimitCounter>();

// Add client identifier provider
services.AddSingleton<IRateLimitClientIdentifierProvider, DefaultClientIdentifierProvider>();

// Add rule provider
services.AddSingleton<IRateLimitRuleProvider, AttributeBasedRuleProvider>();

// Ensure services are registered in the right order
services.AddSingleton<IRateLimiterService, RateLimiterService>();
});
}

public new WebApplicationFactory<TProgram> WithWebHostBuilder(Action<IWebHostBuilder> configure)
{
return base.WithWebHostBuilder(builder =>
{
configure(builder);

// Ensure service registration
builder.ConfigureServices(services =>
{
var serviceRegistrations = services.Where(s => s.ServiceType == typeof(IRateLimiterService)).ToList();
if (!serviceRegistrations.Any() || serviceRegistrations.Any(s => s.Lifetime != ServiceLifetime.Singleton))
{
// Remove any existing non-singleton registrations
foreach (var reg in serviceRegistrations)
{
services.Remove(reg);
}

// Add singleton service
services.AddSingleton<IRateLimiterService, RateLimiterService>();
}
});
});
}

// Helper method to remove all services of a specific type
private static void RemoveAllServiceRegistrationsOf<T>(IServiceCollection services)
{
var serviceDescriptors = services.Where(descriptor => descriptor.ServiceType == typeof(T)).ToList();
foreach (var serviceDescriptor in serviceDescriptors)
{
services.Remove(serviceDescriptor);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using RateLimiter.Core.Configuration;
using RateLimiter.Infrastructure.DependencyInjection;

namespace RateLimiter.IntegrationTests;

public class EnhancedConfigurationOverrideTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public EnhancedConfigurationOverrideTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Configure enhanced hybrid rate limiting with configuration override
services.AddEnhancedHybridRateLimiting(
options =>
{
options.EnableRateLimiting = true;
options.IncludeHeaders = true;
options.HeaderPrefix = "X-RateLimit";
options.StatusCode = 429;
options.ClientIdHeaderName = "X-ClientId";
options.RegionHeaderName = "X-Region";
},
configRules =>
{
configRules.EnableConfigurationRules = true;
configRules.EnableAttributeRules = true;
configRules.ConflictResolutionStrategy = ConflictResolutionStrategy.ConfigurationWins;
configRules.LogConflicts = true;

// Add configuration rule that should override the GlobalLimit attribute
configRules.Rules.Add(new RateLimitRuleConfiguration
{
Name = "BlogProtection",
Type = "FixedWindow",
MaxRequests = 15, // Higher than GlobalLimit (5)
TimeWindowSeconds = 60,
PathPattern = "/api/demo",
HttpMethods = "GET",
Enabled = true,
Priority = 10
});
});
});
});
}

[Fact]
public async Task ConfigurationRule_ShouldOverride_AttributeRule()
{
// Arrange
var client = _factory.CreateClient();

// Act - Make requests beyond the original GlobalLimit (5) but within BlogProtection (15)
var responses = new List<HttpResponseMessage>();
for (int i = 0; i < 10; i++) // More than GlobalLimit (5) but less than BlogProtection (15)
{
var response = await client.GetAsync("/api/demo");
responses.Add(response);

// Small delay to avoid overwhelming
await Task.Delay(50);
}

// Assert
var successfulResponses = responses.Count(r => r.StatusCode == HttpStatusCode.OK);
var blockedResponses = responses.Count(r => r.StatusCode == HttpStatusCode.TooManyRequests);

// With configuration override working, we should get more than 5 successful requests
Assert.True(successfulResponses > 5,
$"Expected more than 5 successful requests (configuration override), but got {successfulResponses}");

// Check headers on first response
var firstResponse = responses.First();
Assert.True(firstResponse.Headers.Contains("X-RateLimit-Rule"));

var ruleHeader = firstResponse.Headers.GetValues("X-RateLimit-Rule").FirstOrDefault();
// Should show configuration rule name, not GlobalLimit
Assert.NotEqual("GlobalLimit", ruleHeader);
}

[Fact]
public async Task ConfigurationRule_HeadersShould_ReflectConfigurationRule()
{
// Arrange
var client = _factory.CreateClient();

// Act
var response = await client.GetAsync("/api/demo");

// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

// Verify configuration rule is active
Assert.True(response.Headers.Contains("X-RateLimit-Limit"));
Assert.True(response.Headers.Contains("X-RateLimit-Rule"));

var limitHeader = response.Headers.GetValues("X-RateLimit-Limit").FirstOrDefault();
var ruleHeader = response.Headers.GetValues("X-RateLimit-Rule").FirstOrDefault();

// Should show higher limit from configuration (15) not attribute (5)
Assert.Equal("15", limitHeader);

// Should show configuration rule name
Assert.Equal("BlogProtection", ruleHeader);
}
}
Loading