Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@
<Nullable>enable</Nullable>

<PackageId>AppRateLimiter.Redis</PackageId>
<Version>1.0.0</Version>
<Version>1.0.1</Version>
<Authors>AppRateLimiter</Authors>
<Description>Redis-backed distributed store for AppRateLimiter. Runs the whole sliding-window calculation in a single atomic Lua script (race-free across instances), uses the Redis server clock, and sets TTL = 2x window.</Description>
<PackageTags>ratelimit;redis;distributed;aspnetcore;throttling</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
</ItemGroup>
Expand Down
83 changes: 83 additions & 0 deletions src/AppRateLimiter.Redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# AppRateLimiter.Redis

Redis-backed distributed store for [AppRateLimiter](https://www.nuget.org/packages/AppRateLimiter).

Use this when you run more than one instance of your app (for example several Kubernetes or EKS pods). The default in-memory store counts requests per process, so with N replicas the effective limit becomes `limit x N`, and a client load balanced across pods can slip past any single pod's counter. This package keeps one shared counter in Redis so the limit holds globally, no matter which instance serves each request.

## Install

```bash
dotnet add package AppRateLimiter.Redis
```

This package depends on `AppRateLimiter`, so the core middleware comes with it.

## Usage

Register the Redis store instead of `AddAppRateLimiter()`. Everything else (the middleware, the IP and claim rules) works exactly as documented in the core package.

```csharp
// Connect with a configuration string:
builder.Services.AddRedisRateLimiter("my-redis:6379");

// Optionally namespace the keys (defaults to "rl:"):
builder.Services.AddRedisRateLimiter("my-redis:6379", keyPrefix: "rl:prod:");
```

If you already manage your own connection, pass an existing `IConnectionMultiplexer` and the store will reuse it:

```csharp
var mux = ConnectionMultiplexer.Connect("my-redis:6379");
builder.Services.AddRedisRateLimiter(mux, keyPrefix: "rl:prod:");
```

Then place the middleware in the pipeline the same way as with the in-memory store:

```csharp
var app = builder.Build();

// IP limiting before authentication.
app.UseRateLimiting(RateLimitRules.ByIp(permitLimit: 100, window: TimeSpan.FromMinutes(1)));

app.UseAuthentication();
app.UseAuthorization();

// Claim limiting after authentication.
app.UseRateLimiting(RateLimitRules.ByClaim("sub", permitLimit: 1000, window: TimeSpan.FromMinutes(1)));

app.Run();
```

## How it stays correct and fast

* **Race free across pods.** The whole sliding window read modify write runs inside a single Lua script, which Redis executes atomically. Concurrent requests from any number of instances cannot race, so there is no over admission.
* **One round trip per check.** The script is cached server side and called via EVALSHA, so each decision is a single pipelined round trip on the shared multiplexer, typically sub millisecond inside the same VPC or AZ. The store is fully async and never blocks thread pool threads.
* **No pod clock skew.** Time comes from the Redis server clock (via `TIME`), not from each pod's clock.
* **Bounded memory.** Each bucket is given a TTL of twice the window (the sliding window counter only needs the current and previous window), so idle keys expire on their own with no manual cleanup.

## Securing Redis in production

The rate limit keys hold client IPs and claim values (such as `sub`), so treat the Redis instance as sensitive infrastructure:

* **Keep it private.** Never expose Redis to the public internet. Put it in a private subnet and allow inbound `6379` only from the app's security group. On EKS, prefer a managed endpoint (such as ElastiCache or MemoryDB) reachable only from the cluster. Leave `protected-mode` on.
* **Require authentication with least privilege.** Use a Redis ACL user limited to the commands this store needs: `EVAL`, `EVALSHA`, `SCRIPT`, `HMGET`, `HSET`, `PEXPIRE`, `TIME`, `PING`.

```
ACL SETUSER ratelimiter on >REPLACE_WITH_STRONG_SECRET ~rl:* +eval +evalsha +script +hmget +hset +pexpire +time +ping
```
* **Encrypt in transit.** Enable `ssl=true` with a real hostname, and turn on in transit and at rest encryption on managed Redis.
* **Never hardcode the connection string.** Load it from a secret (a Kubernetes `Secret` or AWS Secrets Manager), not from source:

```csharp
builder.Services.AddRedisRateLimiter(
builder.Configuration.GetConnectionString("Redis")!,
keyPrefix: "rl:prod:");
```

A secured connection string looks like:
`my-redis.internal:6380,ssl=true,user=ratelimiter,password=<secret>,abortConnect=false`
* **Namespace the keyspace.** Use a distinct `keyPrefix` per app and environment so multiple services can safely share one cluster while their ACL scope (`~rl:*`) stays contained.

## Fails closed

If Redis is unreachable the request errors rather than silently skipping the limit, so an outage cannot be used to bypass rate limiting.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
<!-- Concrete ServiceCollection for the DI configuration test. -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0" />
</ItemGroup>

<ItemGroup>
Expand Down
24 changes: 24 additions & 0 deletions tests/AppRateLimiter.Redis.Tests/RedisRateLimitStoreTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using StackExchange.Redis;
using Microsoft.Extensions.DependencyInjection;
using AppRateLimiter;
using Xunit;

namespace AppRateLimiter.Redis.Tests;
Expand Down Expand Up @@ -77,4 +79,26 @@ public async Task Concurrency_DoesNotOverAdmit()

Assert.Equal(5, results.Count(r => r.Allowed));
}

// Configuring via AddRedisRateLimiter(connectionString, keyPrefix) registers a working
// store (same path the sample app uses) and the custom keyPrefix is applied to the keys.
[SkippableFact]
public async Task AddRedisRateLimiter_ConfiguresStore_AndAppliesKeyPrefix()
{
Skip.IfNot(_fx.Available, "Redis not reachable at " + _fx.Config);
const string prefix = "cfg-rl:";
var provider = new ServiceCollection()
.AddRedisRateLimiter(_fx.Config, prefix)
.BuildServiceProvider();

var store = provider.GetRequiredService<IRateLimitStore>();
var key = NewKey();

for (int i = 0; i < 5; i++)
Assert.True((await store.HitAsync(key, 5, Window, DateTimeOffset.UtcNow)).Allowed);
Assert.False((await store.HitAsync(key, 5, Window, DateTimeOffset.UtcNow)).Allowed);

// The configured prefix is the one actually used for the Redis key.
Assert.True(_fx.Mux!.GetDatabase().KeyExists(prefix + key));
}
}
Loading