Skip to content

Commit

Permalink
David Aboh - implement rate limiter library
Browse files Browse the repository at this point in the history
  • Loading branch information
askgoliath committed Nov 11, 2024
1 parent d0a5741 commit d0594a3
Show file tree
Hide file tree
Showing 34 changed files with 1,183 additions and 7 deletions.
Binary file added .DS_Store
Binary file not shown.
9 changes: 9 additions & 0 deletions RateLimiter.Common/Enum/RateLimitingMethod.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RateLimiter.Common.Enum;

[Flags]
public enum RateLimitingMethod
{
None = 0,
RequestsPerTimespan = 1 << 0,
TimeSpanSinceLastRequest = 1 << 1,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace RateLimiter.Common.Exceptions;

public class InvalidRequestContextException(string message) : Exception(message)
{
public InvalidRequestContextException() : this("The request context is not properly configured. It is missing required information.") { }
}
27 changes: 27 additions & 0 deletions RateLimiter.Common/Exceptions/RateLimitExceededException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace RateLimiter.Common.Exceptions;

public class RateLimitExceededException(string message) : Exception(message)
{
public RateLimitExceededException(string resource, int requestCount, int maxRequests, TimeSpan timeSpan) :
this($"Rate limit exceeded for requested resource ({resource}) : Only {maxRequests} requests allowed per {timeSpan.TotalSeconds} seconds.")
{
Resource = resource;
RequestCount = requestCount;
MaxRequests = maxRequests;
TimeSpan = timeSpan;
}

public RateLimitExceededException(string resource, TimeSpan timeSinceLastRequest, int maxRequests, TimeSpan timeSpan) :
this($"Rate limit exceeded for requested resource ({resource}) : Minimum {timeSinceLastRequest.TotalSeconds} seconds required between requests.")
{
TimeSinceLastRequest = timeSinceLastRequest;
MaxRequests = maxRequests;
TimeSpan = timeSpan;
}

public string Resource { get; } = string.Empty;
public int RequestCount { get; }
public int MaxRequests { get; }
public TimeSpan TimeSpan { get; }
public TimeSpan TimeSinceLastRequest { get; }
}
26 changes: 26 additions & 0 deletions RateLimiter.Common/Model/RateLimitingOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using RateLimiter.Common.Enum;

namespace RateLimiter.Common.Model;

public record RateLimitingOptions
{
/// <summary>
/// The configured method / rule. An enum flag that can contain multiple rules
/// </summary>
public RateLimitingMethod Method { get; set; } = RateLimitingMethod.RequestsPerTimespan;

/// <summary>
/// The maximum number of requests allowed with the specific method.
/// </summary>
public int MaxRequests { get; set; } = 1;

/// <summary>
/// Configured Timespan for this RateLimiting Method
/// </summary>
public TimeSpan TimeSpan { get; set; } = TimeSpan.FromSeconds(1);

/// <summary>
/// The set of origin country codes that this rate limiting method is configured to apply to
/// </summary>
public HashSet<string>? ApplicableCountryCodes;
}
22 changes: 22 additions & 0 deletions RateLimiter.Common/Model/RequestContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace RateLimiter.Common.Model;

/// <summary>
/// Represents Context Info relevant to the current request scope.
/// This contains all the configuration neccessary to perform rate limiting for the request.
/// </summary>
public record RequestContext()
{
public RateLimitingOptions[] Options { get; set; } = [];

public string Resource { get; set; } = string.Empty;

/// <summary>
/// Source Token of the Client making request
/// </summary>
public string Token { get; set; } = string.Empty;

/// <summary>
/// Two-Letter (Alpha-2) Iso Code that represents the country where the request originates
/// </summary>
public string? OriginIsoCountryCode { get; set; }
}
30 changes: 30 additions & 0 deletions RateLimiter.Common/Model/Traffic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace RateLimiter.Common.Model;

/// <summary>
/// Represents request(s) from a source token at a given time.
/// </summary>
/// <param name="Token"></param>
public record Traffic(string Resource, string Token)
{
/// <summary>
/// Identifier for the resource that is being requested.
/// </summary>
public string Resource { get; set; } = Resource;

/// <summary>
/// The token uniquely identifies the traffic source. Traffic entries with the same token came from the same source.
/// </summary>
public string Token { get; init; } = Token;

/// <summary>
/// The time the traffic was recorded
/// </summary>
public DateTime Time { get; init; } = DateTime.UtcNow;

/// <summary>
/// The number of requests associated with this traffic. Usually 1 but could be 2 if multiple requests happen at the same exact time.
/// </summary>
public int Requests { get; init; } = 1;
}

public record TrafficKey(string Resource, string Token);
9 changes: 9 additions & 0 deletions RateLimiter.Common/RateLimiter.Common.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
Binary file added RateLimiter.Repository/.DS_Store
Binary file not shown.
15 changes: 15 additions & 0 deletions RateLimiter.Repository/Configure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RateLimiter.Repository.TrafficRepository;

namespace RateLimiter.Repository;

public static class Configure
{
public static void ConfigureInMemoryRepository(this IHostApplicationBuilder builder)
{
builder.Services.AddSingleton<ITrafficRepository, InMemoryTrafficRepository>();
}
}

1 change: 1 addition & 0 deletions RateLimiter.Repository/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using RateLimiter.Common.Model;
17 changes: 17 additions & 0 deletions RateLimiter.Repository/RateLimiter.Repository.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\RateLimiter.Common\RateLimiter.Common.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
46 changes: 46 additions & 0 deletions RateLimiter.Repository/TrafficRepository/ITrafficRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace RateLimiter.Repository.TrafficRepository;

public interface ITrafficRepository
{
/// <summary>
/// Record new traffic.
/// </summary>
/// <param name="traffic">The traffic to record. </param>
/// <returns></returns>
public Task RecordTraffic(Traffic traffic);

/// <summary>
/// Get the last recorded traffic entry.
/// </summary>
/// <param name="for_token"> the source (token) of the traffic. </param>
/// <param name="for_resource"> the identifier for the resource being requested </param>
/// <returns></returns>
public Task<Traffic?> GetLatestTraffic(string for_token, string for_resource);

/// <summary>
/// Get all the traffic recorded within the specific time span, optionally limited to a specific timespan.
/// </summary>
/// <param name="for_token"> the source (token) of the traffic </param>
/// <param name="for_resource"> the identifier for the resource being requested </param>
/// <param name="within_span"> optional filter operation to traffic within the specific time span. </param>
/// <returns></returns>
public Task<IEnumerable<Traffic>> GetTraffic(string for_token, string for_resource, TimeSpan? within_span = null);

/// <summary>
/// Get the Total number of Requests from the source (token).
/// </summary>
/// <param name="for_token"> the source (token) of the traffic, optionally limited to a specific timespan. </param>
/// <param name="for_resource"> the identifier for the resource being requested </param>
/// <param name="within_span"> optional filter operation to traffic within the specific time span. </param>
/// <returns></returns>
public Task<int> CountTraffic(string for_token, string for_resource, TimeSpan? within_span = default);

/// <summary>
/// Expire / Delete all traffic from the source (token), optionally limited to a specific timespan.
/// </summary>
/// <param name="for_token"> the source (token) of the traffic. </param>
/// <param name="for_resource"> the identifier for the resource being requested </param>
/// <param name="within_span"> optional filter operation to traffic within the specific time span. </param>
/// <returns> number of traffic entries affected/expired </returns>
public Task<int> ExpireTraffic(string for_token, string for_resource, TimeSpan? within_span = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Collections.Concurrent;

namespace RateLimiter.Repository.TrafficRepository;

public class InMemoryTrafficRepository : ITrafficRepository
{
private readonly ConcurrentDictionary<TrafficKey, ConcurrentQueue<Traffic>> _traffic = new();
private readonly object _lock = new();
#region ITrafficRepository

public Task RecordTraffic(Traffic traffic)
{
lock (_lock)
{
TrafficKey key = new(traffic.Resource, traffic.Token);
if (!_traffic.TryGetValue(key, out ConcurrentQueue<Traffic>? queue))
{
queue = new ConcurrentQueue<Traffic>();
_traffic[key] = queue;
}

queue.Enqueue(traffic);
}

return Task.CompletedTask;
}

public Task<Traffic?> GetLatestTraffic(string token, string resource)
{
if (_traffic.TryGetValue(new(resource, token), out ConcurrentQueue<Traffic>? queue) && queue.TryPeek(out Traffic? latestTraffic))
{
return Task.FromResult<Traffic?>(latestTraffic);
}
return Task.FromResult<Traffic?>(null);
}

public Task<IEnumerable<Traffic>> GetTraffic(string token, string resource, TimeSpan? within_span = null)
{
if (!_traffic.TryGetValue(new(resource, token), out ConcurrentQueue<Traffic>? queue))
{
return Task.FromResult(Enumerable.Empty<Traffic>());
}

IEnumerable<Traffic> result = queue
.Where(t => TrafficIsWithinTimeSpan(t, within_span))
.AsEnumerable();

return Task.FromResult(result);
}

public Task<int> CountTraffic(string token, string resource, TimeSpan? within_span = null)
{
if (!_traffic.TryGetValue(new(resource, token), out ConcurrentQueue<Traffic>? queue))
{
return Task.FromResult(0);
}

int count = queue
.Where(t => TrafficIsWithinTimeSpan(t, within_span))
.Sum(t => t.Requests);

return Task.FromResult(count);
}

public Task<int> ExpireTraffic(string token, string resource, TimeSpan? within_span = null)
{
if (!_traffic.TryGetValue(new(resource, token), out ConcurrentQueue<Traffic>? queue))
{
return Task.FromResult(0);
}

int affected = 0;

while (queue.TryPeek(out Traffic? t) && TrafficIsWithinTimeSpan(t, within_span))
{
if (queue.TryDequeue(out _))
{
affected++;
}
}

return Task.FromResult(affected);
}
#endregion

#region Utility
private static bool TrafficIsWithinTimeSpan(Traffic traffic, TimeSpan? span)
{
return !span.HasValue || (DateTime.UtcNow - traffic.Time) <= span.Value;
}
#endregion Utility
}
21 changes: 21 additions & 0 deletions RateLimiter.Services/Configure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RateLimiter.Services.Limiters;
using RateLimiter.Services.RateLimitingService;
using RateLimiter.Services.RequestContextService;

namespace RateLimiter.Services;

public static class Configure
{
public static void ConfigureRateLimiterServices(this IHostApplicationBuilder builder)
{
Repository.Configure.ConfigureInMemoryRepository(builder);
builder.Services.AddScoped<IRequestContextService, ScopedRequestContextService>();
builder.Services.AddScoped<IRateLimitingService, RateLimitingService.RateLimitingService>();
builder.Services.AddScoped<FixedRequestsPerTimeSpanLimiter>();
builder.Services.AddScoped<TimeSpanSinceLastRequestLimiter>();
}
}

3 changes: 3 additions & 0 deletions RateLimiter.Services/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
global using RateLimiter.Common.Model;
global using RateLimiter.Common.Enum;
global using RateLimiter.Common.Exceptions;
22 changes: 22 additions & 0 deletions RateLimiter.Services/Limiters/FixedRequestsPerTimeSpanLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using RateLimiter.Common.Exceptions;
using RateLimiter.Repository.TrafficRepository;

namespace RateLimiter.Services.Limiters;

public class FixedRequestsPerTimeSpanLimiter(ITrafficRepository trafficRepository) : ILimiter
{
public virtual async Task Limit(RateLimitingOptions options, RequestContext context)
{
if (options.MaxRequests <= 0 || options.TimeSpan == TimeSpan.Zero)
{
throw new ArgumentException("Invalid rate limiting configuration: MaxRequests and TimeSpan must be greater than zero.");
}

var requestCount = await trafficRepository.CountTraffic(context.Token, context.Resource, options.TimeSpan);

if (requestCount >= options.MaxRequests)
{
throw new RateLimitExceededException(context.Resource, requestCount, options.MaxRequests, options.TimeSpan);
}
}
}
10 changes: 10 additions & 0 deletions RateLimiter.Services/Limiters/ILimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace RateLimiter.Services.Limiters;

public interface ILimiter
{
/// <summary>
/// Peform a specific Rate Limiting Rule for the provided options
/// </summary>
/// <returns></returns>
Task Limit(RateLimitingOptions options, RequestContext context);
}
Loading

0 comments on commit d0594a3

Please sign in to comment.