Skip to content

Commit fa1233a

Browse files
committed
Rate limiting feature
1 parent f72c0da commit fa1233a

20 files changed

+391
-105
lines changed

Readme.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
Pandatech.DistributedCache is a .NET library providing an efficient and performant abstraction layer over
44
`StackExchange.Redis`, specifically designed for .NET applications. This library builds on top of
55
`StackExchange.Redis.Extensions.AspNetCore` and `StackExchange.Redis.Extensions.MsgPack` to offer a robust, easy-to-use
6-
caching solution with advanced features such as typed cache services, distributed locking, and health checks.
6+
caching solution with advanced features such as typed cache services, distributed locking, business logic rate limiting.
77

88
## Features
99

1010
- **Typed Cache Service:** Supports strongly-typed caching with MessagePack serialization.
1111
- **Distributed Locking:** Ensures data consistency with distributed locks.
12+
- **Distributed Rate Limiting:** Prevents cache abuse with rate limiting based on business logic.
1213
- **Key Isolation:** Modular monolith support by prefixing keys with assembly names.
1314
- **Stampede Protection:** Protects against cache stampede in the `GetOrCreateAsync` method.
1415
- **No Serializer Override:** Enforces MessagePack serialization for performance and readability.
@@ -192,6 +193,53 @@ public interface ICacheService<T> where T : class
192193
}
193194
```
194195

196+
### 5. Rate Limiting
197+
198+
Implement rate limiting using `IRateLimitService` and `RateLimitConfiguration`.
199+
200+
**Define Rate Limiting Configuration**
201+
202+
```csharp
203+
public enum ActionType //your business logic actions
204+
{
205+
SmsForTfa = 1,
206+
EmailForTfa = 2
207+
}
208+
209+
public static class RateLimitingConfigurations //your shared rate limiting configuration
210+
{
211+
public static RateLimitConfiguration GetSmsConfig()
212+
{
213+
return new RateLimitConfiguration
214+
{
215+
ActionType = (int)ActionType.SmsForTfa,
216+
MaxAttempts = 2,
217+
TimeToLive = TimeSpan.FromSeconds(10)
218+
};
219+
}
220+
}
221+
```
222+
223+
**Implement Rate Limiting in the service**
224+
225+
```csharp
226+
using DistributedCache.Dtos;
227+
using DistributedCache.Services.Interfaces;
228+
229+
public class SendSmsService(IRateLimitService rateLimitService)
230+
{
231+
public async Task<RateLimitState> SendSms(CancellationToken cancellationToken = default)
232+
{
233+
var phoneNumber = "1234567890";
234+
var rateLimitConfiguration = RateLimitingConfigurations.GetSmsConfig().SetIdentifiers(phoneNumber);
235+
236+
return await rateLimitService.RateLimitAsync(rateLimitConfiguration, cancellationToken);
237+
}
238+
}
239+
```
240+
241+
Based on rate limit state you can throw exception/return 427 or proceed with the business logic.
242+
195243
## Enforced MessagePack Serialization
196244

197245
`Pandatech.DistributedCache` enforces the use of MessagePack serialization for several compelling reasons:

src/DistributedCache/DistributedCache.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
<PackageReadmeFile>Readme.md</PackageReadmeFile>
99
<Authors>Pandatech</Authors>
1010
<Copyright>MIT</Copyright>
11-
<Version>1.0.0</Version>
11+
<Version>1.1.0</Version>
1212
<PackageId>Pandatech.DistributedCache</PackageId>
1313
<Title>Pandatech Distributed Cache</Title>
1414
<PackageTags>Pandatech, library, redis, distributed locks, cache</PackageTags>
1515
<Description>Pandatech.DistributedCache is a comprehensive caching library designed for .NET applications, leveraging the power of Redis. It provides easy-to-use and highly configurable caching mechanisms, including support for tagged cache entries, customizable expiration policies, and robust health check services. The library also features built-in distributed lock mechanisms to ensure data consistency and prevent cache stampedes. This ensures high performance, scalability, and reliability, making it an ideal choice for enterprise-level distributed caching needs.</Description>
1616
<RepositoryUrl>https://github.com/PandaTechAM/be-lib-distributed-cache</RepositoryUrl>
17-
<PackageReleaseNotes>InitialCommit</PackageReleaseNotes>
17+
<PackageReleaseNotes>Rate limiting feature</PackageReleaseNotes>
1818
</PropertyGroup>
1919

2020
<ItemGroup>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using DistributedCache.Services.Interfaces;
2+
using MessagePack;
3+
4+
namespace DistributedCache.Dtos;
5+
6+
[MessagePackObject]
7+
public class RateLimitCache : ICacheEntity
8+
{
9+
[Key(0)] public int Attempts { get; set; } = 1;
10+
[Key(1)] public int MaxAttempts { get; init; }
11+
[Key(2)] public DateTime Expiration { get; init; }
12+
13+
public static RateLimitCache CreateRateLimitCache(RateLimitConfiguration configuration)
14+
{
15+
return new RateLimitCache
16+
{
17+
MaxAttempts = configuration.MaxAttempts,
18+
Expiration = DateTime.UtcNow + configuration.TimeToLive
19+
};
20+
}
21+
22+
internal bool TryUpdateAttempts()
23+
{
24+
if (Attempts >= MaxAttempts)
25+
{
26+
return false;
27+
}
28+
29+
Attempts++;
30+
return true;
31+
}
32+
33+
internal TimeSpan GetNewExpiration()
34+
{
35+
return Expiration - DateTime.UtcNow;
36+
}
37+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace DistributedCache.Dtos;
2+
3+
public class RateLimitConfiguration : RateLimitKey
4+
{
5+
private readonly int _maxAttempts;
6+
private readonly TimeSpan _timeToLive;
7+
8+
public int MaxAttempts
9+
{
10+
get => _maxAttempts;
11+
init => _maxAttempts = value > 0
12+
? value
13+
: throw new ArgumentOutOfRangeException(nameof(MaxAttempts), "Must be greater than zero.");
14+
}
15+
16+
public TimeSpan TimeToLive
17+
{
18+
get => _timeToLive;
19+
init => _timeToLive = value > TimeSpan.Zero
20+
? value
21+
: throw new ArgumentOutOfRangeException(nameof(TimeToLive), "Must be a positive time span.");
22+
}
23+
24+
public override RateLimitConfiguration SetIdentifiers(string primaryIdentifier, string? secondaryIdentifier = null)
25+
{
26+
PrimaryIdentifier = primaryIdentifier;
27+
SecondaryIdentifier = secondaryIdentifier;
28+
return this;
29+
}
30+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace DistributedCache.Dtos;
2+
3+
public abstract class RateLimitKey
4+
{
5+
public required int ActionType { get; init; }
6+
protected string PrimaryIdentifier { get; set; } = null!;
7+
protected string? SecondaryIdentifier { get; set; }
8+
9+
internal string GetKey()
10+
{
11+
return !string.IsNullOrWhiteSpace(SecondaryIdentifier)
12+
? $"{ActionType}:{PrimaryIdentifier}:{SecondaryIdentifier}:limit"
13+
: $"{ActionType}:{PrimaryIdentifier}:limit";
14+
}
15+
16+
public abstract RateLimitConfiguration SetIdentifiers(string primaryIdentifier, string? secondaryIdentifier = null);
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using DistributedCache.Enums;
2+
3+
namespace DistributedCache.Dtos;
4+
5+
public record RateLimitState(RateLimitStatus Status, TimeSpan TimeToReset, int RemainingAttempts);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace DistributedCache.Enums;
2+
3+
public enum RateLimitStatus
4+
{
5+
Exceeded = 1,
6+
NotExceeded = 2
7+
}

src/DistributedCache/Extensions/WebApplicationBuilderExtension.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ public static WebApplicationBuilder AddDistributedCache(this WebApplicationBuild
3333
builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(redisOptions));
3434

3535
builder.Services.AddSingleton(typeof(ICacheService<>), typeof(RedisCacheService<>));
36+
builder.Services.AddSingleton<RedisLockService>();
37+
builder.Services.AddScoped<IRateLimitService, RedisRateLimitService>();
3638

37-
var redisConfiguration = new RedisConfiguration { ConnectionString = configurations.RedisConnectionString };
39+
var redisConfiguration = new RedisConfiguration
40+
{
41+
ConnectionString = configurations.RedisConnectionString,
42+
Name = "DistributedCacheConfiguration"
43+
};
3844

3945
builder.Services.AddStackExchangeRedisExtensions<RedisMsgPackObjectSerializer>(redisConfiguration);
4046
//builder.Services.AddHostedService<RedisHealthCheckService>(); //Discontinued feature

src/DistributedCache/Services/Implementations/RedisCacheService.cs

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using DistributedCache.Helpers;
1+
using DistributedCache.Dtos;
2+
using DistributedCache.Helpers;
23
using DistributedCache.Options;
34
using DistributedCache.Services.Interfaces;
45
using Microsoft.Extensions.Options;
@@ -7,15 +8,16 @@
78

89
namespace DistributedCache.Services.Implementations;
910

10-
internal class RedisCacheService<T>(IRedisClient redisClient, IOptions<CacheConfigurationOptions> options)
11+
internal class RedisCacheService<T>(
12+
IRedisClient redisClient,
13+
IOptions<CacheConfigurationOptions> options,
14+
RedisLockService lockService)
1115
: ICacheService<T>
1216
where T : class, ICacheEntity
1317
{
1418
private readonly IRedisDatabase _redisDatabase = redisClient.GetDefaultDatabase();
1519
private readonly CacheConfigurationOptions _config = options.Value;
1620
private readonly string _moduleName = typeof(T).Assembly.GetName().Name!;
17-
private readonly TimeSpan _lockExpiry = options.Value.DistributedLockDuration;
18-
private readonly TimeSpan _lockRetryDelay = TimeSpan.FromMilliseconds(10);
1921

2022
public async ValueTask<T> GetOrCreateAsync(string key, Func<CancellationToken, ValueTask<T>> factory,
2123
TimeSpan? expiration = null, IReadOnlyCollection<string>? tags = null, CancellationToken token = default)
@@ -24,18 +26,18 @@ public async ValueTask<T> GetOrCreateAsync(string key, Func<CancellationToken, V
2426
? KeyFormatHelper.GetPrefixedKey(key)
2527
: KeyFormatHelper.GetPrefixedKey(key, _moduleName);
2628

27-
var lockKey = KeyFormatHelper.GetLockKey(prefixedKey);
29+
2830
var lockValue = Guid.NewGuid().ToString();
2931

3032
while (true)
3133
{
3234
token.ThrowIfCancellationRequested();
3335

34-
var isLocked = await _redisDatabase.Database.KeyExistsAsync(lockKey);
36+
var isLocked = await lockService.CheckForLockAsync(prefixedKey);
3537

3638
if (isLocked)
3739
{
38-
await WaitForLockReleaseAsync(lockKey, token);
40+
await lockService.WaitForLockReleaseAsync(prefixedKey, token);
3941
continue;
4042
}
4143

@@ -45,11 +47,11 @@ public async ValueTask<T> GetOrCreateAsync(string key, Func<CancellationToken, V
4547
return cachedValue;
4648
}
4749

48-
var lockAcquired = await AcquireLockAsync(lockKey, lockValue);
50+
var lockAcquired = await lockService.AcquireLockAsync(prefixedKey, lockValue);
4951

5052
if (!lockAcquired)
5153
{
52-
await WaitForLockReleaseAsync(lockKey, token);
54+
await lockService.WaitForLockReleaseAsync(prefixedKey, token);
5355
continue;
5456
}
5557

@@ -64,7 +66,7 @@ public async ValueTask<T> GetOrCreateAsync(string key, Func<CancellationToken, V
6466
}
6567
finally
6668
{
67-
await ReleaseLockAsync(lockKey, lockValue);
69+
await lockService.ReleaseLockAsync(prefixedKey, lockValue);
6870
}
6971
}
7072

@@ -139,30 +141,4 @@ public async ValueTask RemoveByTagsAsync(IEnumerable<string> tags, CancellationT
139141
var tasks = tags.Select(tag => RemoveByTagAsync(tag, token).AsTask());
140142
await Task.WhenAll(tasks);
141143
}
142-
143-
private async Task<bool> AcquireLockAsync(string lockKey, string lockValue)
144-
{
145-
return await _redisDatabase.Database.StringSetAsync(lockKey, lockValue, _lockExpiry, When.NotExists);
146-
}
147-
148-
private async Task WaitForLockReleaseAsync(string lockKey, CancellationToken token)
149-
{
150-
while (await _redisDatabase.Database.KeyExistsAsync(lockKey) && !token.IsCancellationRequested)
151-
{
152-
await Task.Delay(_lockRetryDelay, token);
153-
}
154-
}
155-
156-
private async Task ReleaseLockAsync(string lockKey, string lockValue)
157-
{
158-
// Check if the current instance owns the lock before releasing it
159-
const string script = @"
160-
if redis.call('GET', KEYS[1]) == ARGV[1] then
161-
return redis.call('DEL', KEYS[1])
162-
else
163-
return 0
164-
end";
165-
166-
await _redisDatabase.Database.ScriptEvaluateAsync(script, [lockKey], [lockValue]);
167-
}
168144
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using DistributedCache.Helpers;
2+
using DistributedCache.Options;
3+
using Microsoft.Extensions.Options;
4+
using StackExchange.Redis;
5+
using StackExchange.Redis.Extensions.Core.Abstractions;
6+
7+
namespace DistributedCache.Services.Implementations;
8+
9+
public class RedisLockService(IRedisClient redisClient, IOptions<CacheConfigurationOptions> options)
10+
{
11+
private readonly IRedisDatabase _redisDatabase = redisClient.GetDefaultDatabase();
12+
private readonly TimeSpan _lockExpiry = options.Value.DistributedLockDuration;
13+
private readonly TimeSpan _lockRetryDelay = TimeSpan.FromMilliseconds(10);
14+
15+
public async Task<bool> AcquireLockAsync(string key, string lockValue)
16+
{
17+
var lockKey = KeyFormatHelper.GetLockKey(key);
18+
return await _redisDatabase.Database.StringSetAsync(lockKey, lockValue, _lockExpiry, When.NotExists);
19+
}
20+
21+
public async Task<bool> CheckForLockAsync(string key)
22+
{
23+
var lockKey = KeyFormatHelper.GetLockKey(key);
24+
return await _redisDatabase.Database.KeyExistsAsync(lockKey);
25+
}
26+
27+
public async Task WaitForLockReleaseAsync(string key, CancellationToken token)
28+
{
29+
var lockKey = KeyFormatHelper.GetLockKey(key);
30+
while (await _redisDatabase.Database.KeyExistsAsync(lockKey) && !token.IsCancellationRequested)
31+
{
32+
await Task.Delay(_lockRetryDelay, token);
33+
}
34+
}
35+
36+
public async Task ReleaseLockAsync(string key, string lockValue)
37+
{
38+
var lockKey = KeyFormatHelper.GetLockKey(key);
39+
40+
const string script = @"
41+
if redis.call('GET', KEYS[1]) == ARGV[1] then
42+
return redis.call('DEL', KEYS[1])
43+
else
44+
return 0
45+
end";
46+
47+
await _redisDatabase.Database.ScriptEvaluateAsync(script, [lockKey], [lockValue]);
48+
}
49+
50+
}

0 commit comments

Comments
 (0)