Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lee Behrens #280

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Crexi.RateLimiter.Rule.Model;

namespace Crexi.RateLimiter.Rule.Configuration.Sections;

public class RateLimitRulesConfiguration
{
public IEnumerable<RateLimitRule>? StartupRules { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Crexi.RateLimiter.Rule.Configuration.Sections;

public sealed class RateLimiterConfiguration
{
public long MaxTimeSpanMinutes { get; set; }
public bool UnrecognizedEvaluationTypeEvaluationResult { get; set; }
public int? UnrecognizedEvaluationTypeOverrideResponseCode { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Crexi.RateLimiter.Rule.Configuration.Sections;
using Crexi.RateLimiter.Rule.Execution;
using Crexi.RateLimiter.Rule.Model;
using Crexi.RateLimiter.Rule.Validation;
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Crexi.RateLimiter.Rule.Configuration;

public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the local objects to run.
/// NOTE: Has an unregistered dependencies on TimeProvider and MemoryCache.
/// </summary>
/// <param name="services"></param>
/// <param name="configuration"></param>
/// <returns></returns>
public static IServiceCollection ConfigureRateLimiterRules(this IServiceCollection services,
IConfiguration configuration)
{
return services
.Configure<RateLimiterConfiguration>(configuration.GetSection(key: "RateLimiter"))
.Configure<RateLimitRulesConfiguration>(configuration.GetSection(key: "RateLimiterRules"))
.AddScoped<IValidator<RateLimitRule>, RateLimitRuleValidator>()
.AddScoped<IRuleEvaluationLogic, RuleEvaluationLogic>()
.AddScoped<IRateLimitEngine, RateLimitEngine>();
}
}
22 changes: 22 additions & 0 deletions Crexi.RateLimiter.Rule/Configuration/ServiceProviderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Crexi.RateLimiter.Rule.Configuration.Sections;
using Crexi.RateLimiter.Rule.Execution;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Crexi.RateLimiter.Rule.Configuration;

public static class ServiceProviderExtensions
{
/// <summary>
/// Registers rules from the configuration, if any
/// Should be called after app is started
/// </summary>
/// <param name="serviceProvider"></param>
public static void RegisterStartupRules(this IServiceProvider serviceProvider)
{
var ruleConfiguration = serviceProvider.GetRequiredService<IOptions<RateLimitRulesConfiguration>>()?.Value;
if (ruleConfiguration?.StartupRules is null) return;
var engine = serviceProvider.GetRequiredService<IRateLimitEngine>();
engine.AddUpdateRules(ruleConfiguration.StartupRules);
}
}
7 changes: 7 additions & 0 deletions Crexi.RateLimiter.Rule/Constants/ResultConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Crexi.RateLimiter.Rule.Constants;

internal class ResultConstants
{
internal const int DefaultFailureResponseCode = 429;
internal static readonly (bool success, int? responseCode) SuccessResponse = (true, null);
}
31 changes: 31 additions & 0 deletions Crexi.RateLimiter.Rule/Crexi.RateLimiter.Rule.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<Folder Include="Constants\" />
<Folder Include="ResourceAccess\" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Routing" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Crexi.Ratelimiter.Rule.Abstractions\Crexi.RateLimiter.Rule.Abstractions.csproj" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions Crexi.RateLimiter.Rule/Execution/IRuleEvaluationLogic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Crexi.RateLimiter.Rule.Enum;
using Crexi.RateLimiter.Rule.Model;

namespace Crexi.RateLimiter.Rule.Execution;

public interface IRuleEvaluationLogic
{
(bool success, int? responseCode) EvaluateRule(EvaluationType evaluationType, TimeSpan window, int? maxCallCount, int? overrideResponseCode, CallHistory history);
}
90 changes: 90 additions & 0 deletions Crexi.RateLimiter.Rule/Execution/RateLimitEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Crexi.RateLimiter.Rule.Constants;
using Crexi.RateLimiter.Rule.Extensions;
using Crexi.RateLimiter.Rule.Model;
using Crexi.RateLimiter.Rule.ResourceAccess;
using Crexi.RateLimiter.Rule.Utility;

namespace Crexi.RateLimiter.Rule.Execution;

public class RateLimitEngine(IRateLimitResourceAccess resourceAccess, TimeProvider timeProvider, IRuleEvaluationLogic logic): IRateLimitEngine
{
public void AddUpdateRules(IEnumerable<RateLimitRule> rules)
{
var comparer = new UpdateRateLimitRuleComparer();
var ruleSets = rules.GroupBy(r => r.ToCallData(), new CallDataComparer());
foreach (var ruleSet in ruleSets)
{
var existingRuleDictionary = resourceAccess.GetRules(ruleSet.Key)?
.ToDictionary(comparer.GetHashCode) ?? new Dictionary<int, RateLimitRule>();
foreach (var rule in ruleSet)
{
var hash = comparer.GetHashCode(rule);
existingRuleDictionary[hash] = rule;
}
resourceAccess.SetRules(existingRuleDictionary.Values, ruleSet.Key);
resourceAccess.SetExpirationWindow(TimeSpan.FromMilliseconds(existingRuleDictionary.Values.Max(r => r.Timespan)), ruleSet.Key);
}
}

public (bool success, int? responseCode) Evaluate(CallData callData)
{
var rules = GetMostSpecificRulesForCallData(callData);
if (rules is null || rules.Count == 0) return ResultConstants.SuccessResponse;
var callHistory = resourceAccess.AddCallAndGetHistory(callData);
return EvaluateRules(rules, callHistory);
}

#region private methods

private IList<RateLimitRule>? GetMostSpecificRulesForCallData(CallData callData)
{
IList<RateLimitRule>? rules;
do
{
rules = resourceAccess.GetRules(callData);
} while (rules is null && TryDeSpecifyCallDataByOne(callData));
return rules;
}

private static bool TryDeSpecifyCallDataByOne(CallData callData)
{
if (callData.RegionId.HasValue)
{
callData.RegionId = null;
return true;
}
if (callData.TierId.HasValue)
{
callData.TierId = null;
return true;
}
if (callData.ClientId.HasValue)
{
callData.ClientId = null;
return true;
}
return false;
}

private (bool success, int? responseCode) EvaluateRules(IList<RateLimitRule> rules, CallHistory callHistory)
{
foreach (var rule in rules)
{
if (!IsInEffectiveWindow(rule)) continue;
var result = logic.EvaluateRule(rule.EvaluationType, TimeSpan.FromMilliseconds(rule.Timespan), rule.MaxCallCount, rule.OverrideResponseCode, callHistory);
if (!result.success)
return (false, rule.OverrideResponseCode ?? ResultConstants.DefaultFailureResponseCode);
}
return ResultConstants.SuccessResponse;
}

private bool IsInEffectiveWindow(RateLimitRule rule)
{
if (rule.EffectiveWindowStartUtc is null)
return true;
var currentUtc = TimeOnly.FromDateTime(timeProvider.GetUtcNow().DateTime);
return rule.EffectiveWindowStartUtc <= currentUtc && rule.EffectiveWindowEndUtc >= currentUtc;
}

#endregion private methods
}
46 changes: 46 additions & 0 deletions Crexi.RateLimiter.Rule/Execution/RuleEvaluationLogic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Crexi.RateLimiter.Rule.Configuration.Sections;
using Crexi.RateLimiter.Rule.Constants;
using Crexi.RateLimiter.Rule.Enum;
using Crexi.RateLimiter.Rule.Model;
using Microsoft.Extensions.Options;

namespace Crexi.RateLimiter.Rule.Execution;

public class RuleEvaluationLogic(TimeProvider timeProvider, IOptions<RateLimiterConfiguration> configuration) : IRuleEvaluationLogic
{
public (bool success, int? responseCode) EvaluateRule(EvaluationType evaluationType, TimeSpan window, int? maxCallCount, int? overrideResponseCode, CallHistory history) => evaluationType switch
{
EvaluationType.TimespanSinceLastCall => EvaluateTimespanSinceLastCall(window, overrideResponseCode, history),
EvaluationType.CallsDuringTimespan => EvaluateCallsDuringTimespan(window, maxCallCount, overrideResponseCode, history),
EvaluationType.WeAreTeasing => EvaluateWeAreTeasing(),
EvaluationType.WeArePseudoConfusing => EvaluateWeArePseudoConfusing(),
_ => configuration.Value.UnrecognizedEvaluationTypeEvaluationResult ? ResultConstants.SuccessResponse : (false, configuration.Value.UnrecognizedEvaluationTypeOverrideResponseCode ?? ResultConstants.DefaultFailureResponseCode)
};

private (bool success, int? responseCode) EvaluateTimespanSinceLastCall(TimeSpan window,
int? overrideResponseCode,
CallHistory history)
{
var result =
timeProvider.GetUtcNow().DateTime.Subtract(window) > history.LastCall
? ResultConstants.SuccessResponse
: (false, overrideResponseCode ?? ResultConstants.DefaultFailureResponseCode);
return result;
}

private (bool success, int? responseCode) EvaluateCallsDuringTimespan(TimeSpan window, int? maxCallCount, int? overrideResponseCode, CallHistory history)
{
if (history.Calls is null || history.Calls.Length == 0) return ResultConstants.SuccessResponse;
var measure = timeProvider.GetUtcNow().DateTime.Subtract(window);
var windowCallCount = history.Calls.Count(c => c >= measure);
/*
NOTE: not handling null call count. I'd handle it via config values in a real implementation, but here assuming rule validation will prevent errors here.
*/
return windowCallCount < maxCallCount! ? ResultConstants.SuccessResponse
: (false, overrideResponseCode ?? ResultConstants.DefaultFailureResponseCode);
}

private static (bool success, int? responseCode) EvaluateWeAreTeasing() => (false, 406);

private static (bool success, int? responseCode) EvaluateWeArePseudoConfusing() => (new Random().Next() % 2 == 0, 418);
}
14 changes: 14 additions & 0 deletions Crexi.RateLimiter.Rule/Extensions/RateLimitRuleExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Crexi.RateLimiter.Rule.Model;

namespace Crexi.RateLimiter.Rule.Extensions;

public static class RateLimitRuleExtensions
{
public static CallData ToCallData(this RateLimitRule rule) => new()
{
RegionId = rule.RegionId,
TierId = rule.TierId,
ClientId = rule.ClientId,
Resource = rule.Resource,
};
}
73 changes: 73 additions & 0 deletions Crexi.RateLimiter.Rule/ResourceAccess/RateLimitResourceAccess.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Crexi.RateLimiter.Rule.Model;
using Crexi.RateLimiter.Rule.Utility;
using Microsoft.Extensions.Caching.Memory;

namespace Crexi.RateLimiter.Rule.ResourceAccess;

public class RateLimitResourceAccess(IMemoryCache memoryCache, TimeProvider timeProvider): IRateLimitResourceAccess
{
private readonly CallDataComparer _callDataComparer = new();
private const string CacheOptionSuffix = "_CacheOption";
private const string RulesSuffix = "_Rules";

public CallHistory AddCallAndGetHistory(CallData callData)
{
var hash = _callDataComparer.GetHashCode(callData);
var callUtc = timeProvider.GetUtcNow().DateTime;
var calls = new List<DateTime>() { callUtc };
DateTime? latestCall = null;
/*
NOTE: I fully understand this is almost certainly breaking, but for the stated purpose of the test (We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm), I'm going to pretend it will never break...
*/
foreach (var key in ((MemoryCache)memoryCache).Keys)
{
if (key is not string strKey || !strKey.StartsWith($"{hash}_Call_"))
continue;
var value = memoryCache.Get<DateTime>(key);
calls.Add(value);
latestCall = latestCall is null ? value : Max(latestCall.Value, value);
}
AddNewCallToCache(hash, callUtc);
return new CallHistory()
{
Calls = calls.ToArray(),
LastCall = latestCall,
};
}

public IList<RateLimitRule>? GetRules(CallData callData)
{
var hash = _callDataComparer.GetHashCode(callData);
return memoryCache.TryGetValue<IList<RateLimitRule>>($"{hash}_{RulesSuffix}", out var rules) ? rules : null;
}

public IRateLimitResourceAccess SetExpirationWindow(TimeSpan timespan, CallData callData)
{
var hash = _callDataComparer.GetHashCode(callData);
memoryCache.Set($"{hash}_{CacheOptionSuffix}", new MemoryCacheEntryOptions()
{
AbsoluteExpirationRelativeToNow = timespan
});
return this;
}

public IRateLimitResourceAccess SetRules(IEnumerable<RateLimitRule> rules, CallData callData)
{
var hash = _callDataComparer.GetHashCode(callData);
/*
NOTE: Since rules shouldn't expire or get evicted, MemoryCache really isn't the right storage, but as I said before, for this test, I'll pretend it's functional.
*/
memoryCache.Set($"{hash}_{CacheOptionSuffix}", rules);
return this;
}

private void AddNewCallToCache(int callDataHash, DateTime callUtc)
{
var callKey = $"{callDataHash}_Call_{Guid.NewGuid()}";
var options = memoryCache.Get<MemoryCacheEntryOptions>($"{callDataHash}_{CacheOptionSuffix}");
memoryCache.Set(callKey, callUtc, options);
}

private static DateTime Max(DateTime dt1, DateTime dt2) =>
dt1 > dt2 ? dt1 : dt2;
}
20 changes: 20 additions & 0 deletions Crexi.RateLimiter.Rule/Utility/CallDataComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Crexi.RateLimiter.Rule.Model;

namespace Crexi.RateLimiter.Rule.Utility;

public class CallDataComparer: IEqualityComparer<CallData>
{
public bool Equals(CallData? x, CallData? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null) return false;
if (y is null) return false;
if (x.GetType() != y.GetType()) return false;
return x.RegionId == y.RegionId && x.TierId == y.TierId && x.ClientId == y.ClientId && x.Resource == y.Resource;
}

public int GetHashCode(CallData obj)
{
return HashCode.Combine(obj.RegionId, obj.TierId, obj.ClientId, obj.Resource);
}
}
Loading