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
16 changes: 16 additions & 0 deletions RateLimiter.Core/Configuration/ConsoleLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;
using RateLimiter.Core.Configuration.Contracts;

namespace RateLimiter.Core.Configuration;

public class ConsoleLogger : ILogger
{
public void LogInformation(string message)
=> Console.WriteLine($"[INFO] {DateTime.UtcNow:O} {message}");

public void LogWarning(string message)
=> Console.WriteLine($"[WARN] {DateTime.UtcNow:O} {message}");

public void LogError(string message, Exception ex = null)
=> Console.WriteLine($"[ERROR] {DateTime.UtcNow:O} {message} {ex?.ToString()}");
}
12 changes: 12 additions & 0 deletions RateLimiter.Core/Configuration/Contracts/ILogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace RateLimiter.Core.Configuration.Contracts;
/// <summary>
/// It can be AWS.Logger.NLog or Microsoft.Extensions.Logging
/// </summary>
public interface ILogger
{
void LogInformation(string message);
void LogWarning(string message);
void LogError(string message, Exception ex = null!);
}
7 changes: 7 additions & 0 deletions RateLimiter.Core/Configuration/Contracts/IRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Core.Configuration.Contracts;

public interface IRateLimitRule
{
bool IsAllowed(string clientToken, string resourceKey);
void RecordRequest(string clientToken, string resourceKey);
}
8 changes: 8 additions & 0 deletions RateLimiter.Core/Configuration/Contracts/ISystemTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace RateLimiter.Core.Configuration.Contracts;

public interface ISystemTime
{
DateTime GetCurrentUtcTime();
}
15 changes: 15 additions & 0 deletions RateLimiter.Core/Configuration/MockSystemTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using RateLimiter.Core.Configuration.Contracts;

namespace RateLimiter.Core.Configuration;

/// <summary>
/// Only for test
/// </summary>
/// <param name="startTime">Test start time</param>
public class MockSystemTime(DateTime startTime) : ISystemTime
{
public DateTime CurrentTime { get; set; } = startTime;

public DateTime GetCurrentUtcTime() => CurrentTime;
}
78 changes: 78 additions & 0 deletions RateLimiter.Core/Configuration/RateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using RateLimiter.Core.Configuration.Contracts;

namespace RateLimiter.Core.Configuration;
public class RateLimiter(ILogger logger = null!)
{
private readonly Dictionary<string, List<IRateLimitRule>> _resourceRules = new();

public void AddRule(string resourceKey, IRateLimitRule rule)
{
if (string.IsNullOrEmpty(resourceKey))
throw new ArgumentNullException(nameof(resourceKey));

if (rule == null)
throw new ArgumentNullException(nameof(rule));

try
{
if (!_resourceRules.ContainsKey(resourceKey))
_resourceRules[resourceKey] = new List<IRateLimitRule>();

_resourceRules[resourceKey].Add(rule);
logger.LogInformation($"Added rule {rule.GetType().Name} for resource {resourceKey}");
}
catch (Exception ex)
{
logger.LogError($"Error adding rule for resource {resourceKey}", ex);
throw;
}
}


public bool IsRequestAllowed(string clientToken, string resourceKey)
{
if (string.IsNullOrEmpty(clientToken))
throw new ArgumentNullException(nameof(clientToken));

if (string.IsNullOrEmpty(resourceKey))
throw new ArgumentNullException(nameof(resourceKey));

try
{
if (!_resourceRules.TryGetValue(resourceKey, out var rules) || !rules.Any())
{
logger.LogWarning($"No rules configured for resource {resourceKey}");
return true;
}

foreach (var rule in rules.Where(rule => !rule.IsAllowed(clientToken, resourceKey)))
{
logger.LogWarning($"Request blocked by {rule.GetType().Name} " +
$"for {clientToken} on {resourceKey}");
return false;
}

foreach (var rule in rules)
{
try
{
rule.RecordRequest(clientToken, resourceKey);
}
catch (Exception ex)
{
logger.LogError($"Error recording request in {rule.GetType().Name}", ex);
}
}

return true;
}
catch (Exception ex)
{
logger.LogError($"Error processing request for {clientToken} on {resourceKey}", ex);
return false;
}
}
}
11 changes: 11 additions & 0 deletions RateLimiter.Core/Configuration/SystemSystemTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System;
using RateLimiter.Core.Configuration.Contracts;

namespace RateLimiter.Core.Configuration;
/// <summary>
/// Real time usage
/// </summary>
public class SystemSystemTime : ISystemTime
{
public DateTime GetCurrentUtcTime() => DateTime.UtcNow;
}
8 changes: 8 additions & 0 deletions RateLimiter.Core/Exceptions/FixedWindowRuleException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace RateLimiter.Core.Exceptions;

public class FixedWindowRuleException : Exception
{

}
8 changes: 8 additions & 0 deletions RateLimiter.Core/Exceptions/RegionBasedRuleException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace RateLimiter.Core.Exceptions;

public class RegionBasedRuleException : Exception
{

}
7 changes: 7 additions & 0 deletions RateLimiter.Core/Exceptions/TimeSinceLastCallRuleException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System;

namespace RateLimiter.Core.Exceptions;

public class TimeSinceLastCallRuleException : Exception
{
}
8 changes: 8 additions & 0 deletions RateLimiter.Core/PolicyProviders/DatabasePolicyProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RateLimiter.Core.PolicyProviders;
/// <summary>
/// TODO can be used for Get Policy from DB
/// </summary>
public class DatabasePolicyProvider
{

}
8 changes: 8 additions & 0 deletions RateLimiter.Core/PolicyProviders/JsonPolicyProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RateLimiter.Core.PolicyProviders;
/// <summary>
/// TODO can be used for Get Policy from app_settings or local file
/// </summary>
public class JsonPolicyProvider
{

}
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
75 changes: 75 additions & 0 deletions RateLimiter.Core/Rules/Combine/RegionBasedRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System;
using RateLimiter.Core.Configuration;
using RateLimiter.Core.Configuration.Contracts;

namespace RateLimiter.Core.Rules.Combine;

public class RegionBasedRule(
IRateLimitRule usRule,
IRateLimitRule euRule,
ILogger logger = null!) : IRateLimitRule
{
private readonly IRateLimitRule _usRule = usRule ?? throw new ArgumentNullException(nameof(usRule));
private readonly IRateLimitRule _euRule = euRule ?? throw new ArgumentNullException(nameof(euRule));

private readonly ILogger _logger = logger ?? new ConsoleLogger();

public bool IsAllowed(string clientToken, string resourceKey)
{
try
{
var region = GetRegionFromToken(clientToken);
_logger.LogInformation($"Processing {region} region request for {clientToken}");

return region switch
{
"US" => _usRule.IsAllowed(clientToken, resourceKey),
"EU" => _euRule.IsAllowed(clientToken, resourceKey),
_ => HandleUnknownRegion(clientToken)
};
}
catch (Exception ex)
{
_logger.LogError($"Error in RegionBasedRule.IsAllowed for {clientToken}", ex);
return false;
}
}

private bool HandleUnknownRegion(string clientToken)
{
_logger.LogWarning($"Unknown region for token {clientToken}");
return false;
}

private static string GetRegionFromToken(string token)
{
try
{
var parts = token.Split('-');
if (parts.Length < 1 || string.IsNullOrEmpty(parts[0]))
throw new FormatException("Invalid token format");

return parts[0].ToUpper();
}
catch
{
throw new FormatException($"Invalid token format: {token}");
}
}

public void RecordRequest(string clientToken, string resourceKey)
{
var region = GetRegionFromToken(clientToken);
switch (region)
{
case "US":
_usRule.RecordRequest(clientToken, resourceKey);
break;
case "EU":
_euRule.RecordRequest(clientToken, resourceKey);
break;
default:
throw new NotSupportedException($"Region {region} not supported.");
}
}
}
92 changes: 92 additions & 0 deletions RateLimiter.Core/Rules/FixedWindowRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using RateLimiter.Core.Configuration;
using RateLimiter.Core.Configuration.Contracts;

namespace RateLimiter.Core.Rules;
public class FixedWindowRule : IRateLimitRule
{
private readonly int _maxRequests;
private readonly TimeSpan _windowDuration;
private readonly ISystemTime _systemTime;
private readonly ILogger _logger;

private readonly Dictionary<string, Dictionary<string, (int Count, DateTime WindowStart)>> _clientResourceData = new();

public FixedWindowRule(int maxRequests, TimeSpan windowDuration, ISystemTime systemTime = null, ILogger logger = null)
{
if (maxRequests <= 0)
throw new ArgumentException("Max requests must be positive", nameof(maxRequests));
_maxRequests = maxRequests;
_windowDuration = windowDuration;
_logger = logger;
_systemTime = systemTime;
}

public bool IsAllowed(string clientToken, string resourceKey)
{
if (string.IsNullOrEmpty(clientToken))
throw new ArgumentNullException(nameof(clientToken));

if (string.IsNullOrEmpty(resourceKey))
throw new ArgumentNullException(nameof(resourceKey));

try
{
lock (_clientResourceData)
{
var currentTime = _systemTime.GetCurrentUtcTime();
var data = GetOrInitializeData(clientToken, resourceKey, currentTime);

_logger.LogInformation($"Client {clientToken} resource {resourceKey}: " +
$"{data.Count}/{_maxRequests} requests in current window");

return data.Count < _maxRequests;
}
}
catch (Exception ex)
{
_logger.LogError($"Error in FixedWindowRule.IsAllowed for {clientToken}", ex);
return false;
}
}

public void RecordRequest(string clientToken, string resourceKey)
{
lock (_clientResourceData)
{
var currentTime = _systemTime.GetCurrentUtcTime();
var data = GetOrInitializeData(clientToken, resourceKey, currentTime);
data.Count++;
UpdateData(clientToken, resourceKey, data.Count, data.WindowStart);
}
}

private (int Count, DateTime WindowStart) GetOrInitializeData(string clientToken, string resourceKey, DateTime currentTime)
{
if (!_clientResourceData.TryGetValue(clientToken, out var resourceData))
{
resourceData = new Dictionary<string, (int, DateTime)>();
_clientResourceData[clientToken] = resourceData;
}

if (!resourceData.TryGetValue(resourceKey, out var data))
{
data = (0, currentTime);
resourceData[resourceKey] = data;
}

if (currentTime - data.WindowStart > _windowDuration)
{
data = (0, currentTime);
resourceData[resourceKey] = data;
}

return data;
}

private void UpdateData(string clientToken, string resourceKey, int count, DateTime windowStart)
{
_clientResourceData[clientToken][resourceKey] = (count, windowStart);
}
}
Loading