GithubHelp home page GithubHelp logo

cristipufu / aspnetcore-redis-rate-limiting Goto Github PK

View Code? Open in Web Editor NEW
207.0 4.0 22.0 577 KB

Set up a Redis backplane for ASP.NET Core multi-node deployments, using the built-in Rate Limiting support that's part of .NET 7 and .NET 8.

License: MIT License

C# 100.00%
asp-net-core aspnetcore net7 rate-limit rate-limiter rate-limiting redis distributed rate-limit-redis rate-limiter-api

aspnetcore-redis-rate-limiting's Introduction

aspnetcore-redis-rate-limiting

NuGet NuGet Nuget Coverage Code Smells Vulnerabilities GitHub

Set up a Redis backplane for Rate Limiting ASP.NET Core multi-node deployments. The library is build on top of the built-in Rate Limiting support that's part of .NET 7 and .NET 8.

For more advanced use cases you can check out the official documentation here.

install

PM> Install-Package RedisRateLimiting
TargetFramework: net7.0; net8.0

Dependencies:
StackExchange.Redis
System.Threading.RateLimiting
PM> Install-Package RedisRateLimiting.AspNetCore
TargetFramework: net7.0; net8.0

Dependencies:
RedisRateLimiting

strategies

Concurrent Requests Rate Limiting


Concurrency Rate Limiter limits how many concurrent requests can access a resource. If your limit is 10, then 10 requests can access a resource at once and the 11th request will not be allowed. Once the first request completes, the number of allowed requests increases to 1, when the second request completes, the number increases to 2, etc.

Instead of "You can use our API 1000 times per second", this rate limiting strategy says "You can only have 20 API requests in progress at the same time".


concurrency


You can use a new instance of the RedisConcurrencyRateLimiter class or configure the predefined extension method:

builder.Services.AddRateLimiter(options =>
{
    options.AddRedisConcurrencyLimiter("demo_concurrency", (opt) =>
    {
        opt.ConnectionMultiplexerFactory = () => connectionMultiplexer;
        opt.PermitLimit = 5;
        // Queue requests when the limit is reached
        //opt.QueueLimit = 5 
    });
});

concurrent_queuing_requests


Fixed Window Rate Limiting


The Fixed Window algorithm uses the concept of a window. The window is the amount of time that our limit is applied before we move on to the next window. In the Fixed Window strategy, moving to the next window means resetting the limit back to its starting point.


fixed_window


You can use a new instance of the RedisFixedWindowRateLimiter class or configure the predefined extension method:

builder.Services.AddRateLimiter(options =>
{
    options.AddRedisFixedWindowLimiter("demo_fixed_window", (opt) =>
    {
        opt.ConnectionMultiplexerFactory = () => connectionMultiplexer;
        opt.PermitLimit = 1;
        opt.Window = TimeSpan.FromSeconds(2);
    });
});

Sliding Window Rate Limiting


Unlike the Fixed Window Rate Limiter, which groups the requests into a bucket based on a very definitive time window, the Sliding Window Rate Limiter, restricts requests relative to the current request's timestamp. For example, if you have a 10 req/minute rate limiter, on a fixed window, you could encounter a case where the rate-limiter allows 20 requests during a one minute interval. This can happen if the first 10 requests are on the left side of the current window, and the next 10 requests are on the right side of the window, both having enough space in their respective buckets to be allowed through. If you send those same 20 requests through a Sliding Window Rate Limiter, if they are all sent during a one minute window, only 10 will make it through.


fixed_window


You can use a new instance of the RedisSlidingWindowRateLimiter class or configure the predefined extension method:

builder.Services.AddRateLimiter(options =>
{
    options.AddRedisSlidingWindowLimiter("demo_sliding_window", (opt) =>
    {
        opt.ConnectionMultiplexerFactory = () => connectionMultiplexer;
        opt.PermitLimit = 1;
        opt.Window = TimeSpan.FromSeconds(2);
    });
});

Token Bucket Rate Limiting


Token Bucket is an algorithm that derives its name from describing how it works. Imagine there is a bucket filled to the brim with tokens. When a request comes in, it takes a token and keeps it forever. After some consistent period of time, someone adds a pre-determined number of tokens back to the bucket, never adding more than the bucket can hold. If the bucket is empty, when a request comes in, the request is denied access to the resource.


token_bucket


You can use a new instance of the RedisTokenBucketRateLimiter class or configure the predefined extension method:

builder.Services.AddRateLimiter(options =>
{
    options.AddRedisTokenBucketLimiter("demo_token_bucket", (opt) =>
    {
        opt.ConnectionMultiplexerFactory = () => connectionMultiplexer;
        opt.TokenLimit = 2;
        opt.TokensPerPeriod = 1;
        opt.ReplenishmentPeriod = TimeSpan.FromSeconds(2);
    });
});

snippets

These samples intentionally keep things simple for clarity.


aspnetcore-redis-rate-limiting's People

Contributors

altso avatar cristipufu avatar dependabot[bot] avatar dlxeon avatar hacst avatar jacksga avatar kamilslusarczykdotdigital avatar namoshek avatar robert-ursu avatar swintdc avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

aspnetcore-redis-rate-limiting's Issues

Handle Redis not available

What happens if we can't connect to Redis?
Should we fail the request?
Should we swallow the exception?
Should we configure the behavior?

Chained limiters

Hi,

Will it be possible to manage chained limiters in future releases?

Thanks in advance.

RedisSlidingWindowRateLimiter does not provide IdleDuration

The current implementation always returns TimeSpan.Zero for RateLimiter.IdleDuration. When using a partitioned rate limiter this means the rate limiters created for the partitions never get cleaned up and will accumulate.

I think this is true for all rate limiters in this repo however as it is the only one I am using right now, I only looked at the sliding window rate limiter in detail so far. For that the most reasonable approach to me seems to be to accept some imprecision and just track the last expireat value set in the RedisSlidingWindowManager and provide an estimated idle duration for that. This is under the assumption that if a manager ever deletes a partition that was not actually fully idle, it is really no problem to have it just be re-created by the factory. The actual limit is still in redis after all.

I could create a pull request for the redis sliding rate limiter if this approach is acceptable.

This would not fully match what the interface wants though. The documentation says:

Specifies how long the RateLimiter has had all permits available.

A better estimate or precise answer could of course be given by reaching out to redis, however to perform potentially blocking operations in there seems problematic to me.

Distributed system concurency

Hello,

That is a great package. I have tested with multiple instances of same api behind a loadbalancer. It seems to be working well. But have you tried it with high volume of concurent/paralel requests. Is the counter atomic? And also when you hit the limit, it increments the counter value by number of instances.
Also , its response time is above 2000ms when using client based limiter with multiple instances of app.
Thanks :)

permitCount paramater values larger than 1 are currently not supported

The parameter permitCount is passed to both AttemptAcquire and AcquireAsync in the RateLimiter abstract base class that all rate limiters in this library are are implementing.

The definition for this paramater is as follows:
<param name="permitCount">Number of permits to try and acquire.</param>
(See for instance here: https://github.com/dotnet/runtime/blob/43a60c8ed073a4c6134facadd01c9c1c2643e41a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/RateLimiter.cs#L60)

Yet, all the provided classes disregard this parameter value, as in here:

protected override ValueTask<RateLimitLease> AcquireAsyncCore(int permitCount, CancellationToken cancellationToken)
{
if (permitCount > _options.PermitLimit)
{
throw new ArgumentOutOfRangeException(nameof(permitCount), permitCount, string.Format("{0} permit(s) exceeds the permit limit of {1}.", permitCount, _options.PermitLimit));
}
return AcquireAsyncCoreInternal();
}

In some cases, a hard coded value of 1D is then passed on instead of the parameter, as in here:

var response = (RedisValue[]?)await database.ScriptEvaluateAsync(
_redisScript,
new
{
rate_limit_key = RateLimitKey,
expires_at_key = RateLimitExpireKey,
next_expires_at = now.Add(_options.Window).ToUnixTimeSeconds(),
current_time = nowUnixTimeSeconds,
increment_amount = 1D,
});
var result = new RedisFixedWindowResponse();

Are there plans to solve this?
Also, If this is currently a known limitation of this library (fair), please provide a warning in the documentation.

Thanks.

RedisSlidingWindowRateLimiter breaks if configured with sub-second precision Window

RedisSlidingWindowManager uses the EXPIREAT redis call to update the expiry every time it attempts to acquire a lease. This function only accepts a whole number of seconds as its argument. However the expression (RedisValue)_options.Window.TotalSeconds, is used when passing the window size into the lua script where TotalSeconds is a double.

This usually works because RedisValue turns double into an integer if it can be exactly represented. But if a window size with sub-second precision is specified, the value is passed as a double and an error occurs.

The obvious fix is to just cast total seconds to long to truncate it before conversion to a RedisValue. Alternatively if sub-second precision is actually wanted Redis also supports millisecond precision for expiry through PEXPIREAT since version 2.6.0.

If I know which option is preferred I can create a PR.

[Feature Request] Adding Rules based on config?

Being able to apply Rules to a controller's action by just defining endpoint and limit settings in appsettings.json similar to how it is done in AspNetCoreRateLimit.

Want to be able to dynamically assign rules to each action instead of having to manually doing it (as some projects are too big to do it manually)

The RedisTokenBucketRateLimiter refills the bucket immediately after some timeout

According to the documentation, the RedisTokenBucketRateLimiter is supposed to refill the bucket by TokensPerPeriod every ReplenishmentPeriod until TokenLimit is reached. Because refilling the bucket is no constant process, this is done whenever the rate limit is accessed through the Lua script.

To prevent stale rate limit entries in Redis, the Lua script calculates and sets a TTL for the rate limit keys:

local fill_time = limit / rate
local ttl = math.floor(fill_time * 2)

The calculated TTL, however, does not seem to be correct. fill_time is the number of ReplenishmentPeriods it takes, until the bucket is full again. This leads to a valid TTL if ReplenishmentPeriod is less than 2 seconds. But in case the ReplenishmentPeriod is higher, like 15 seconds, the TTL is wrong. Which, in case no requests occur for the duration of the TTL, leads to a bucket which is filled early.

Example of a problematic rate limiter:

builder.Services.AddRateLimiter(options =>
{
    options.AddRedisTokenBucketLimiter("MyLimit", (opt) =>
    {
        opt.ConnectionMultiplexerFactory = () => connectionMultiplexer;
        opt.TokenLimit = 3;
        opt.TokensPerPeriod = 1;
        opt.ReplenishmentPeriod = TimeSpan.FromSeconds(15);
    });
});

The fix for this should be simple, but I'll have to look into it.

Send rate limit headers when request is successful

Custom middleware

public interface IRateLimiterPolicy<TPartitionKey>
{
    Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; }
+   Func<OnAcquiredContext, CancellationToken, ValueTask>? OnAcquired { get; }
}
public sealed class RateLimiterOptions
{
    public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get; set; }
+    public Func<OnAcquiredContext, CancellationToken, ValueTask>? OnAcquired { get; set; }
}

dotnet/aspnetcore#44140

ClientIdRateLimiterPolicy sample never returns 429

Issued the GET /clients several times and never receive a 429 response. This endpoint is using the ClientIdRateLimiterPolicy. I thought it could be because the swagger did not send the X-ClientId header so I added code to set clientId to "unknown" to test that theory. I also see that it is able to connect to azure redis because I see the key using redis cli.
Is this endpoint suppose to issue a 429 response when several requests are made one after another? I hit the submit several times and never see the 429 response. I do get a 429 response for the endpoint GET /FixedWindow

Configurable prefix for redis key

Hey,

thanks for providing this useful package. I was wondering if there is a way to configure a prefix for the redis key? Currently, it could lead to problems if several applications share the same redis database.

Thanks in advance.

Rate limiter causes thread pool exhaustion

When trying to load test the sliding window limiter I noticed that it quickly lead to redis timeouts especially in environments with limited cpu count and/or and a bit of latency to redis. This seems to be caused by exhausting the thread pool. Looking into the issue I found the following: The TryAcquireAsync function in RateLimitingMiddleware.cs first does a synchronous AttemptAcquire call before falling back to before falling back to AcquireAsync on the RateLimiter. This means unless something goes wrong, only synchronous StackExchange Redis calls are performed by this package.

Looking at the documentation on the RateLimiter class it says:

AcquireAsync(Int32, CancellationToken) Wait until the requested permits are available or permits can no longer be acquired.
AttemptAcquire(Int32) Fast synchronous attempt to acquire permits.

So this isn't just the usual case of having an async and a blocking implementation but meant to be distinct functions that can both be used.

I have to admit that I am not sure I fully get the intent of the interface with regards to waiting until a permit is available. But I think the way to get proper scalability would probably be to never return a lease from the synchronous call and only reach out to redis in the asynchronous one.

Would this be an appropriate change? Bit surprised I am the first one to stumble over this so maybe I am on the wrong track completely.

RedisTokenBucketRateLimiter on AWS Serverless Redis Cache

RedisTokenBucketRateLimiter throws an error on Amazon ElastiCache Serverless for Redis. Every other limiter type works just fine.

StackExchange.Redis.RedisServerException: ERR This Redis command is not allowed from script script: 53aa7d296ba9ded783301cc275161b6e344ad383, on @user_script:13.
      at StackExchange.Redis.RedisDatabase.ScriptEvaluateAsync(String script, RedisKey[] keys, RedisValue[] values, CommandFlags flags) in /_/src/StackExchange.Redis/RedisDatabase.cs:line 1551
      at RedisRateLimiting.Concurrency.RedisTokenBucketManager.TryAcquireLeaseAsync()
      at RedisRateLimiting.RedisTokenBucketRateLimiter`1.AcquireAsyncCoreInternal()

Unable to get whether permits are exhausted / wait until permits are replenished

According to the definitions of AttemptAcquire and AcquireAsync, when given permitCount = 0, it is possible to check if the permits are exhausted or wait until the permits are replenished, respectively.

Here are the links to the definitions:

https://learn.microsoft.com/en-us/dotnet/api/system.threading.ratelimiting.ratelimiter.attemptacquire?view=aspnetcore-7.0

https://learn.microsoft.com/en-us/dotnet/api/system.threading.ratelimiting.ratelimiter.acquireasync?view=aspnetcore-7.0

I would be happy to know if there is any workaround to check the actual state of the permits without acquiring a lease.

Add metadata to also send header X-Rate-Limit-Reset

A header "X-Rate-Limit-Reset" containing for example "2023-04-21T11:21:43.6820378Z" would be a nice addition.

Since the data is available on the RedisFixedWindowResponse class, it should be possible to add it.

See pull request: #55

Lua script attempted to access a non local key in a cluster node

Using AWS ElasticCache:

StackExchange.Redis.RedisServerException: ERR Error running script (call to f_d3a858ae047422548b35753d09e9d2fe57ec91c0): @user_script:2: @user_script: 2: Lua script attempted to access a non local key in a cluster node
   at StackExchange.Redis.ConnectionMultiplexer.ExecuteSyncImpl[T](Message message, ResultProcessor`1 processor, ServerEndPoint server, T defaultValue) in /_/src/StackExchange.Redis/ConnectionMultiplexer.cs:line 1909
   at StackExchange.Redis.RedisDatabase.ScriptEvaluate(String script, RedisKey[] keys, RedisValue[] values, CommandFlags flags) in /_/src/StackExchange.Redis/RedisDatabase.cs:line 1501
   at StackExchange.Redis.RedisDatabase.ScriptEvaluate(LuaScript script, Object parameters, CommandFlags flags) in /_/src/StackExchange.Redis/RedisDatabase.cs:line 1536
   at RedisRateLimiting.Concurrency.RedisFixedWindowManager.TryAcquireLease()
   at RedisRateLimiting.RedisFixedWindowRateLimiter`1.AttemptAcquireCore(Int32 permitCount)

Redis Error: WRONGTYPE Operation against a key holding the wrong kind of value

I wanted to use this library and did some testing. This was my code:

`services.AddRateLimiter(options =>
{
options.OnRejected = (context, _) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
}

    context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
    context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.");

    return new ValueTask();
};
options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            string clientId = GetClientFromContext(httpContext);
            return RedisRateLimitPartition.GetFixedWindowRateLimiter(clientId, _ =>
               new RedisFixedWindowRateLimiterOptions
               {
                   ConnectionMultiplexerFactory = () => connectionMultiplexer,
                   PermitLimit = 2,
                   Window = TimeSpan.FromSeconds(1)                                   
               });
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            string clientId = GetClientFromContext(httpContext);
            return RateLimitPartition.GetNoLimiter(clientId);
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            string clientId = GetClientFromContext(httpContext);
            return RedisRateLimitPartition.GetConcurrencyRateLimiter(clientId, _ =>
               new RedisConcurrencyRateLimiterOptions
               {
                   ConnectionMultiplexerFactory = () => connectionMultiplexer,
                   PermitLimit = 5,
                   QueueLimit = 10
               });
        })
        );

});`

It worked as expected for a while, and at some point I started getting this error from Redis:

"ERR Error running script (call to f_a08ae7b80fbefc1d082f3c02f112bb4f38a59fa7): @user_script:8: WRONGTYPE Operation against a key holding the wrong kind of value"

I looked in Redis monitor and saw the error as a result of this command:
"evalsha" "a08ae7b80fbefc1d082f3c02f112bb4f38a59fa7" "3" "rl:{tabdevweb.ini}" "rl:{tabdevweb.ini}:q" "rl:{tabdevweb.ini}:stats" "2" "1000" "0" "1693226580" "rl:{tabdevweb.ini}" "rl:{tabdevweb.ini}:q" "b2ec810f-80ac-4c50-b2b9-e46aad0de33c" "rl:{tabdevweb.ini}:stats"

After a while (next day) the error disappeared and now its working fine again.

Thanks

[Feature Request] .Net 8 support

Hi @cristipufu,

My team is preparing to upgrade from .Net6 to .Net8 (due for GA in November) and I noticed this library is .Net7 only. Do you have any plans to add .Net8 support soon?

If not, is there anything holding you back where perhaps we could be of help?

Cheers, Jeroen

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.