Skip to content

Redis centralized rate limiting, GCRA #65792

Open

Description

@mgravell

Related: #41861, #53426

Summary

Redis is soon to gain an inbuilt GCRA rate limiter. Support is being added via SE.Redis.

Questions here:

  1. should this (and can this) be used as an implementation of the asp-net rate-limiting abstraction?
  2. if "yes" to 1, where should that be? here? or in a Redis-owned package?

Relevant context / disclosure:

  1. asp-net already has relevant SE.Redis dependencies, but these would need to be rev'd
  2. I'm the main SE.Redis maintainer
  3. I work for Redis
  4. I used to be part of the asp-net team

So: I'm happy to offer my time here towards an implementation, as it fits both my knowledge and objectives. I'm also entirely happy to just say "hey, this API exists; have fun" if people don't want that. But if we do want such an API, then the interesting question is "where?" - would this go "here" alongside the other implementations, or would we want to keep things isolated, i.e. I create a Redis-owned package under the redis org.

I wanted to discuss it before jumping in.

(pinging @DeagleGross and @BrennanConroy just to help me with routing)

Activity

  1. added
    design-proposalThis issue represents a design proposal for a different issue, linked in the description
    on Mar 16, 2026
  2. mgravell commented on Mar 16, 2026

    @mgravell
    ContributorAuthor

    Redis command is† GCRA key max_burst requests_per_period period [NUM_REQUESTS count]; I'm assuming the key here would be post-partitioner, so presumably something along the lines of:

    builder.Services.AddRateLimiter(options =>
    {
        options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
            SomethingRedis.GetGcraLimiter(
                partitionKey: ...
                factory: partition => new GcraRateLimiterOptions
                {
                    RequestsPerPeriod = ...,
                    Period = ...,
                    MaxBurst = ...,
                }));
    });

    There's also the question of whether to support the variable cost aspect ([NUM_REQUESTS count] defaults to 1, and is the number of tokens consumed by a given request).

    I'm not precious about anything naming... there might be a "Distributed" or something wanted in there somehows.


    †: currently not merged, so: subject to change

  3. added
    feature-rate-limitWork related to use of rate limit primitives
    area-middlewareIncludes: URL rewrite, redirect, response cache/compression, session, and other general middlewares
    on Mar 17, 2026
  4. DeagleGross commented on Mar 23, 2026

    @DeagleGross
    Member

    Hey @mgravell, thanks for sharing plans about GCRA limiter. Just acknowledging we have seen this issue posted, and we'll come back to you once we have more detailed understanding of how we want to build this.

  5. mgravell commented on Apr 17, 2026

    @mgravell
    ContributorAuthor

    @DeagleGross any updates?

  6. DeagleGross commented on Apr 20, 2026

    @DeagleGross
    Member

    @mgravell sorry, was busy handling other stuff - I'll prioritize and hopefully return here this week. Thanks for keeping this in mind!

  7. self-assigned this
    on Apr 21, 2026
  8. DeagleGross commented on Apr 22, 2026

    @DeagleGross
    Member

    I've spent some time analyzing what pieces we have now, and below is the explanation of thought + proposal for what we probably want for GCRA. I am not as expert'ish as @mgravell with Redis and RateLimiting in general, so please correct me where you think you would make adjustments.

    To be fair, since we will probably be only introducing the Microsoft.AspNetCore.RateLimiting.StackExchangeRedis package with this GCRA effort, I need to spend time looking into different existing issues in the aspnetcore's ratelimiting space (example is #42691). It will help us build a better API from the get-go, and make less adjustments later I hope.

    Also in the meantime I'll spin up some conversation with one of the known packages that provide this kind of integration between Redis and AspNetCore: https://github.com/cristipufu/aspnetcore-redis-rate-limiting.


    API Proposal: Redis GCRA Rate Limiter

    Related: #41861, #53426, #42691

    Motivation

    ASP.NET Core has a rate limiting middleware (shipped in .NET 7) with four in-process algorithms: fixed window, sliding window, token bucket, and concurrency limiter. All of them store state in-memory, meaning each app server tracks limits independently. With N servers, a client can achieve N× the intended rate by distributing requests across servers.

    There is currently no distributed rate limiting support in ASP.NET Core. Issues #41861 and #53426 have been open since 2022/2024 requesting this.

    Redis is adding a native GCRA (Generic Cell Rate Algorithm) command. GCRA is uniquely suited for distributed rate limiting because:

    1. Single atomic command — No Lua scripts or multi-key transactions needed
    2. Minimal state — Stores exactly one timestamp per key (the Theoretical Arrival Time)
    3. Native burst control — max_burst parameter controls burst allowance
    4. Rich response — Returns remaining, retry_after, and reset_after in one call

    StackExchange.Redis is adding client support via StringGcraRateLimitAsync().

    Current State of Code

    Rate limiting abstractions (dotnet/runtime — System.Threading.RateLimiting)

    The runtime provides the abstract base classes and in-process implementations:

    // Base abstraction — two acquire paths:
    abstract class RateLimiter
    {
        RateLimitLease AttemptAcquire(int permitCount);           // sync, fast path
        ValueTask<RateLimitLease> AcquireAsync(int permitCount);  // async, can queue
    }
    
    // Partition factory methods for in-process algorithms:
    RateLimitPartition.GetTokenBucketLimiter<TKey>(partitionKey, factory)
    RateLimitPartition.GetSlidingWindowLimiter<TKey>(partitionKey, factory)
    RateLimitPartition.GetFixedWindowLimiter<TKey>(partitionKey, factory)
    RateLimitPartition.GetConcurrencyLimiter<TKey>(partitionKey, factory)
    RateLimitPartition.Get<TKey>(partitionKey, factory)            // generic

    Rate limiting middleware (dotnet/aspnetcore — Microsoft.AspNetCore.RateLimiting)

    The aspnetcore repo provides middleware integration and convenience APIs:

    builder.Services.AddRateLimiter(options =>
    {
        options.AddTokenBucketLimiter("myPolicy", opts => { ... });
        options.AddSlidingWindowLimiter("otherPolicy", opts => { ... });
        options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(...);
    });
    app.UseRateLimiter();
    app.MapGet("/api", handler).RequireRateLimiting("myPolicy");

    Existing Redis packages in aspnetcore

    ASP.NET Core already ships four packages that depend on StackExchangeRedis:

    Package Purpose
    Microsoft.Extensions.Caching.StackExchangeRedis IDistributedCache implementation
    Microsoft.AspNetCore.SignalR.StackExchangeRedis SignalR scale-out backplane
    Microsoft.AspNetCore.DataProtection.StackExchangeRedis Shared encryption key storage
    Microsoft.AspNetCore.OutputCaching.StackExchangeRedis Shared output cache store

    All follow the same pattern: a separate NuGet package in the aspnetcore repo that depends on StackExchange.Redis and implements an ASP.NET Core abstraction backed by Redis.

    What's missing

    There is no distributed RateLimiter implementation. The gap:

    Caching:    IDistributedCache (abstraction) → MemoryDistributedCache | RedisCache
    RateLimiting: RateLimiter (abstraction)     → TokenBucket | SlidingWindow | ??? Redis
    

    Design Principles

    • Plugs into existing middleware — Same AddRateLimiter(), UseRateLimiter(), RequireRateLimiting() pipeline. Users swap an in-process limiter for a Redis-backed one by changing the registration, not the middleware.
    • Follows established Redis package pattern — Separate NuGet package, same connection options shape as the other four Redis packages (Configuration, ConfigurationOptions, ConnectionMultiplexerFactory, InstanceName).
    • No runtime repo changes required — The generic RateLimitPartition.Get<TKey>() factory already exists. No new abstractions needed in System.Threading.RateLimiting.
    • Resilient by default — Configurable behavior when Redis is unavailable (fail-open or fail-closed).

    Architecture

    Package: Microsoft.AspNetCore.RateLimiting.StackExchangeRedis

    A new package in src/Middleware/RateLimiting.StackExchangeRedis/ following the same structure as Microsoft.AspNetCore.OutputCaching.StackExchangeRedis.

    src/Middleware/RateLimiting.StackExchangeRedis/
    ├── src/
    │   ├── Microsoft.AspNetCore.RateLimiting.StackExchangeRedis.csproj
    │   ├── RedisGcraRateLimiter.cs              // RateLimiter subclass
    │   ├── RedisGcraRateLimiterOptions.cs       // GCRA + Redis connection options
    │   ├── RedisGcraRateLimitLease.cs           // Lease with retry-after metadata
    │   ├── RedisFailurePolicy.cs                // Enum: FailOpen / FailClosed
    │   └── RedisGcraRateLimiterExtensions.cs    // AddRedisGcraLimiter() extensions
    └── test/
        └── ...
    

    Dependencies:

    • StackExchange.Redis (NuGet)
    • Microsoft.AspNetCore.RateLimiting (project reference)

    How it fits the existing pipeline

                        ┌────────────────────────────────────┐
                        │ dotnet/runtime                      │
                        │ System.Threading.RateLimiting       │
                        │   RateLimiter (abstract)            │
                        │   PartitionedRateLimiter<T>         │
                        │   RateLimitPartition.Get<TKey>()    │
                        │   TokenBucketRateLimiter (in-proc)  │
                        │   SlidingWindowRateLimiter (in-proc)│
                        └───────────────┬────────────────────┘
                                        │ references
                        ┌───────────────┴────────────────────┐
                        │ dotnet/aspnetcore                   │
                        │ Microsoft.AspNetCore.RateLimiting   │
                        │   AddRateLimiter()                  │
                        │   UseRateLimiter()                  │
                        │   RateLimitingMiddleware            │
                        │   AddTokenBucketLimiter() etc.      │
                        └───────────────┬────────────────────┘
                                        │ references
      ┌─────────────────────────────────┴──────────────────────────────┐
      │ dotnet/aspnetcore (NEW)                                        │
      │ Microsoft.AspNetCore.RateLimiting.StackExchangeRedis           │
      │   RedisGcraRateLimiter : RateLimiter                           │
      │   AddRedisGcraLimiter() extension                              │
      │   + StackExchange.Redis dependency                             │
      └────────────────────────────────────────────────────────────────┘
    

    Middleware flow with Redis GCRA

    The existing RateLimitingMiddleware has a two-phase acquire:

    TryAcquireAsync():
      1. CombinedAcquire()     — calls AttemptAcquire() [sync]
      2. CombinedWaitAsync()   — calls AcquireAsync()   [async]
    

    For Redis GCRA:

    • AttemptAcquire() → returns a "not acquired" lease (cannot do sync Redis calls). No permits consumed.
    • The middleware naturally falls back to AcquireAsync() → executes the Redis GCRA command.
    • The async overhead is negligible compared to the Redis network round-trip.

    API

    New types in Microsoft.AspNetCore.RateLimiting.StackExchangeRedis

    namespace Microsoft.AspNetCore.RateLimiting.StackExchangeRedis;
    
    /// <summary>
    /// Options for a Redis-backed GCRA rate limiter.
    /// </summary>
    public sealed class RedisGcraRateLimiterOptions
    {
        // === GCRA parameters ===
    
        /// <summary>
        /// Maximum number of requests allowed per <see cref="Period"/>.
        /// </summary>
        public int RequestsPerPeriod { get; set; }
    
        /// <summary>
        /// The time window for rate limiting.
        /// </summary>
        public TimeSpan Period { get; set; }
    
        /// <summary>
        /// Additional requests allowed as burst beyond the sustained rate.
        /// Total instantaneous capacity is MaxBurst + 1.
        /// </summary>
        public int MaxBurst { get; set; }
    
        // === Redis connection (same shape as RedisCacheOptions, RedisOutputCacheOptions) ===
    
        /// <summary>
        /// The Redis configuration string.
        /// </summary>
        public string? Configuration { get; set; }
    
        /// <summary>
        /// The Redis configuration options. Preferred over <see cref="Configuration"/>.
        /// </summary>
        public ConfigurationOptions? ConfigurationOptions { get; set; }
    
        /// <summary>
        /// Factory to create the ConnectionMultiplexer instance.
        /// Preferred over <see cref="Configuration"/> and <see cref="ConfigurationOptions"/>.
        /// </summary>
        public Func<Task<IConnectionMultiplexer>>? ConnectionMultiplexerFactory { get; set; }
    
        /// <summary>
        /// Prefix for Redis keys. Allows partitioning a single Redis instance
        /// for multiple apps. If set, rate limiter keys are prefixed with this value.
        /// </summary>
        public string? InstanceName { get; set; }
    
        // === Resilience ===
    
        /// <summary>
        /// Behavior when Redis is unavailable. Default: <see cref="RedisFailurePolicy.FailOpen"/>.
        /// </summary>
        public RedisFailurePolicy FailurePolicy { get; set; } = RedisFailurePolicy.FailOpen;
    }
    
    /// <summary>
    /// Determines behavior when Redis is unavailable.
    /// </summary>
    public enum RedisFailurePolicy
    {
        /// <summary>Allow all requests when Redis is unavailable.</summary>
        FailOpen,
        /// <summary>Reject all requests when Redis is unavailable.</summary>
        FailClosed
    }
    namespace Microsoft.AspNetCore.RateLimiting.StackExchangeRedis;
    
    /// <summary>
    /// A rate limiter backed by the Redis GCRA command.
    /// Provides distributed, centralized rate limiting across multiple app instances.
    /// </summary>
    public sealed class RedisGcraRateLimiter : RateLimiter
    {
        /// <summary>
        /// Creates a new <see cref="RedisGcraRateLimiter"/>.
        /// </summary>
        public RedisGcraRateLimiter(string redisKey, RedisGcraRateLimiterOptions options);
    
        /// <inheritdoc/>
        /// <remarks>
        /// Always returns a lease with <see cref="RateLimitLease.IsAcquired"/> set to false,
        /// because Redis operations are inherently async. The middleware will fall back
        /// to <see cref="RateLimiter.AcquireAsync"/>.
        /// </remarks>
        protected override RateLimitLease AttemptAcquireCore(int permitCount);
    
        /// <inheritdoc/>
        /// <remarks>
        /// Executes the Redis GCRA command. The <paramref name="permitCount"/> is passed
        /// as the TOKENS parameter, supporting variable-cost requests.
        /// On success, the returned lease includes <see cref="MetadataName.RetryAfter"/> metadata.
        /// </remarks>
        protected override ValueTask<RateLimitLease> AcquireAsyncCore(
            int permitCount, CancellationToken cancellationToken);
    
        /// <inheritdoc/>
        public override RateLimiterStatistics? GetStatistics();
    
        /// <inheritdoc/>
        public override TimeSpan? IdleDuration { get; }
    }

    Extension methods

    namespace Microsoft.AspNetCore.RateLimiting;
    
    public static class RedisGcraRateLimiterExtensions
    {
        /// <summary>
        /// Registers a Redis GCRA rate limiter with the given policy name.
        /// All requests matching this policy share a single Redis key.
        /// </summary>
        public static RateLimiterOptions AddRedisGcraLimiter(
            this RateLimiterOptions options,
            string policyName,
            Action<RedisGcraRateLimiterOptions> configureOptions);
    
        /// <summary>
        /// Registers a Redis GCRA rate limiter with per-partition keys.
        /// Each distinct partition key maps to a separate Redis key.
        /// </summary>
        public static RateLimiterOptions AddRedisGcraLimiter<TPartitionKey>(
            this RateLimiterOptions options,
            string policyName,
            Func<HttpContext, TPartitionKey> partitionKeySelector,
            Action<RedisGcraRateLimiterOptions> configureOptions)
            where TPartitionKey : notnull;
    }

    Lease metadata

    The Redis GCRA response includes retry_after_s, remaining, and reset_after_s. Rejected leases expose MetadataName.RetryAfter so the OnRejected callback can set the Retry-After response header:

    options.OnRejected = async (context, ct) =>
    {
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        }
    };

    Usage Examples

    Global rate limit (all requests share one Redis key)

    builder.Services.AddRateLimiter(options =>
    {
        options.AddRedisGcraLimiter("globalPolicy", opts =>
        {
            opts.Configuration = "localhost:6379";
            opts.RequestsPerPeriod = 1000;
            opts.Period = TimeSpan.FromMinutes(1);
            opts.MaxBurst = 50;
            opts.InstanceName = "myapp:ratelimit:";
        });
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    });
    
    app.UseRateLimiter();
    app.MapGet("/api/data", handler).RequireRateLimiting("globalPolicy");

    Per-user rate limit

    builder.Services.AddRateLimiter(options =>
    {
        options.AddRedisGcraLimiter<string>("perUserPolicy",
            httpContext => httpContext.User.Identity?.Name ?? "anonymous",
            opts =>
            {
                opts.Configuration = "localhost:6379";
                opts.RequestsPerPeriod = 100;
                opts.Period = TimeSpan.FromMinutes(1);
                opts.MaxBurst = 10;
            });
    });

    Mixed: in-process + Redis

    builder.Services.AddRateLimiter(options =>
    {
        // In-process concurrency limit (existing)
        options.AddConcurrencyLimiter("concurrencyPolicy", opts =>
        {
            opts.PermitLimit = 10;
        });
    
        // Distributed rate limit (new)
        options.AddRedisGcraLimiter<string>("distributedPolicy",
            httpContext => httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            opts =>
            {
                opts.Configuration = "localhost:6379";
                opts.RequestsPerPeriod = 60;
                opts.Period = TimeSpan.FromMinutes(1);
                opts.MaxBurst = 5;
            });
    });
    
    app.MapGet("/api/fast", handler).RequireRateLimiting("concurrencyPolicy");
    app.MapGet("/api/slow", handler).RequireRateLimiting("distributedPolicy");

    Advanced: direct PartitionedRateLimiter usage

    For users who want full control (e.g., as a GlobalLimiter), the RedisGcraRateLimiter can be used with the generic RateLimitPartition.Get<TKey>() factory:

    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        RateLimitPartition.Get(
            partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: key => new RedisGcraRateLimiter(
                redisKey: key,
                options: new RedisGcraRateLimiterOptions
                {
                    ConnectionMultiplexerFactory = () => getSharedMultiplexer(),
                    RequestsPerPeriod = 100,
                    Period = TimeSpan.FromMinutes(1),
                    MaxBurst = 10,
                    InstanceName = "myapp:global:",
                })));

    Behavioral Details

    AttemptAcquire (sync fast path)

    RedisGcraRateLimiter.AttemptAcquire() returns a lease with IsAcquired = false without consuming any permits. The middleware's TryAcquireAsync method then falls back to AcquireAsync, which performs the Redis call. This follows the existing contract — AttemptAcquire is documented as "fast synchronous attempt" and returning a failed lease is valid behavior.

    Variable cost

    The permitCount parameter from AcquireAsync(int permitCount) is passed as the TOKENS parameter to the Redis GCRA command. This supports variable-cost scenarios (e.g., a bulk upload costs 10 tokens, a read costs 1) without additional API.

  9. mgravell commented on Apr 22, 2026

    @mgravell
    ContributorAuthor

    Wow, that's a huge piece of analysis; kudos and appreciated. Let me know what I can do to help!

  10. DeagleGross commented on Apr 27, 2026

    @DeagleGross
    Member

    @mgravell thanks; I think if you want to explore what kind of specific API you imagine should happen in separate Microsoft.AspNetCore.RateLimiting.StackExchangeRedis package - I would be happy to hear your thoughts. I talked to @halter73 and confirmed it seems like we should ship this package. If you dont want to do this experimental work - that is fine, I dont want you to waste your time here.

    I may not have a lot of time to move this further soon'ish, but hopefully we'll move it to some degree until net11, and I can try to come up with some rough API of the package (MVP branch), where you can see GCRA bits and adjust per your expertise. I'll keep you posted

  11. mgravell commented on Apr 27, 2026

    @mgravell
    ContributorAuthor

    Awesome. Keep me in the loop. Server 8.8 should be shopping any time soon, although @philon-msft might need to advise on when that ships inside AMR etc. I can point you at docker images for local/CI hosting.

  12. mgravell commented on May 8, 2026

    @mgravell
    ContributorAuthor

    @DeagleGross well shoot, GCRA got yanked from 8.8; the server team are re-evaluating, and I suspect are planning to replace the feature with something similar but not the same; I think we need to park this for now. Sorry. I'm actively yanking code :(

  13. DeagleGross commented on May 9, 2026

    @DeagleGross
    Member

    @mgravell no worries, it is still great you raised this question about having Redis centric library from aspnetcore side. Please, let us know if you see any movement with the GCRA happening, and we can consider working on this for net11 or more realistically for net12 already.

  14. mgravell commented on May 9, 2026

    @mgravell
    ContributorAuthor

    I believe the plan is to broaden the scope - i.e. GCRA is only a very specific implementation, but rate limiting should be a wider feature with GCRA as but one option. So it might be AGoodThingTM

  15. lqlive commented on Jun 30, 2026

    @lqlive
    Contributor

    Hi @DeagleGross, I’ve been working on a small prototype for Microsoft.AspNetCore.RateLimiting.StackExchangeRedis.

    Given that the native Redis GCRA command is currently parked, the prototype uses a Lua script for the atomic GCRA operation, but keeps the API aligned with the proposal above.

    I pasted the current prototype below only to show the implementation direction, not as a final API proposal.

    Would this Lua-based GCRA implementation be an acceptable direction to explore? If so, would you be open to a draft PR for review and API discussion?

    using System.Diagnostics;
    using System.Text;
    using System.Threading.RateLimiting;
    using StackExchange.Redis;
    
    namespace Microsoft.AspNetCore.RateLimiting.StackExchangeRedis;
    
    public sealed class RedisGcraRateLimiter : RateLimiter
    {
        private static readonly RateLimitLease SuccessfulLease = new RedisGcraRateLimitLease(isAcquired: true, retryAfter: null);
        private static readonly RateLimitLease FailedLease = new RedisGcraRateLimitLease(isAcquired: false, retryAfter: null);
    
        private const string AcquireScript = """
            local key    = KEYS[1]
            local burst  = tonumber(ARGV[1])
            local rate   = tonumber(ARGV[2])
            local period = tonumber(ARGV[3])
            local cost   = tonumber(ARGV[4])
            local t   = redis.call('TIME')
            local now = (tonumber(t[1]) * 1000) + math.floor(tonumber(t[2]) / 1000)
            local emission_interval = period / rate
            local increment         = emission_interval * cost
            local burst_offset      = emission_interval * (burst + 1)
            local tat = redis.call('GET', key)
            if tat then tat = tonumber(tat) else tat = now end
            if tat < now then tat = now end
            local new_tat  = tat + increment
            local allow_at = new_tat - burst_offset
            local diff     = now - allow_at
            if diff < 0 then
              return { 0, 0, math.ceil(-diff), math.ceil(tat - now) }
            end
            local remaining   = math.floor(diff / emission_interval)
            local reset_after = new_tat - now
            local ttl = math.ceil(reset_after)
            if ttl > 0 then redis.call('SET', key, new_tat, 'PX', ttl) end
            return { 1, remaining, -1, math.ceil(reset_after) }
            """;
    
        private const string PeekScript = """
            local key    = KEYS[1]
            local burst  = tonumber(ARGV[1])
            local rate   = tonumber(ARGV[2])
            local period = tonumber(ARGV[3])
            local t   = redis.call('TIME')
            local now = (tonumber(t[1]) * 1000) + math.floor(tonumber(t[2]) / 1000)
            local emission_interval = period / rate
            local burst_offset      = emission_interval * (burst + 1)
            local tat = redis.call('GET', key)
            if tat then tat = tonumber(tat) else tat = now end
            if tat < now then tat = now end
            local diff      = now - (tat - burst_offset)
            local remaining = math.floor(diff / emission_interval)
            if remaining < 0 then remaining = 0 end
            return remaining
            """;
    
        private readonly RedisGcraRateLimiterOptions _options;
        private readonly SemaphoreSlim _connectionLock = new(initialCount: 1, maxCount: 1);
        private readonly RedisKey[] _keys;
        private readonly int _maxBurst;
        private readonly int _requestsPerPeriod;
        private readonly long _periodMs;
    
        private volatile IDatabase? _store;
        private bool _disposed;
    
        public RedisGcraRateLimiter(RedisGcraRateLimiterOptions options)
        {
            ArgumentNullException.ThrowIfNull(options);
    
            _options = options;
            _keys = [(RedisKey)Encoding.UTF8.GetBytes(options.InstanceName)];
            _maxBurst = options.MaxBurst;
            _requestsPerPeriod = options.RequestsPerPeriod;
            _periodMs = (long)options.Period.TotalMilliseconds;
        }
        public override TimeSpan? IdleDuration => null;
    
        public override RateLimiterStatistics? GetStatistics()
        {
            CheckDisposed();
    
            try
            {
                var store = Connect();
                var values = new RedisValue[] { _maxBurst, _requestsPerPeriod, _periodMs };
                long remaining = (long)store.ScriptEvaluate(PeekScript, _keys, values);
                return new RateLimiterStatistics
                {
                    CurrentAvailablePermits = remaining,
                    TotalSuccessfulLeases = 0,
                    TotalFailedLeases = 0,
                };
            }
            catch (Exception) when (_options.FailurePolicy == RedisFailurePolicy.FailOpen)
            {
                return new RateLimiterStatistics
                {
                    CurrentAvailablePermits = _maxBurst + 1,
                    TotalSuccessfulLeases = 0,
                    TotalFailedLeases = 0,
                };
            }
            catch (Exception) when (_options.FailurePolicy == RedisFailurePolicy.FailClosed)
            {
                return new RateLimiterStatistics
                {
                    CurrentAvailablePermits = 0,
                    TotalSuccessfulLeases = 0,
                    TotalFailedLeases = 0,
                };
            }
        }
    
        protected override async ValueTask<RateLimitLease> AcquireAsyncCore(int permitCount, CancellationToken cancellationToken)
        {
            CheckDisposed();
    
            ThrowIfPermitCountOutOfRange(permitCount);
    
            if (permitCount == 0)
            {
                return SuccessfulLease;
            }
    
            cancellationToken.ThrowIfCancellationRequested();
    
            try
            {
                var store = await ConnectAsync(cancellationToken).ConfigureAwait(false);
                var result = await store.ScriptEvaluateAsync(AcquireScript, _keys, BuildArgs(permitCount)).ConfigureAwait(false);
                return BuildLease(result);
            }
            catch (Exception exception) when (exception is not OperationCanceledException && _options.FailurePolicy == RedisFailurePolicy.FailOpen)
            {
                return SuccessfulLease;
            }
            catch (Exception exception) when (exception is not OperationCanceledException && _options.FailurePolicy == RedisFailurePolicy.FailClosed)
            {
                return FailedLease;
            }
        }
    
        protected override RateLimitLease AttemptAcquireCore(int permitCount)
        {
            CheckDisposed();
    
            ThrowIfPermitCountOutOfRange(permitCount);
    
            if (permitCount == 0)
            {
                return SuccessfulLease;
            }
    
            return FailedLease;
        }
    
        private void ThrowIfPermitCountOutOfRange(int permitCount)
        {
            var permitLimit = _maxBurst + 1;
            if (permitCount > permitLimit)
            {
                throw new ArgumentOutOfRangeException(nameof(permitCount), permitCount, $"{permitCount} permit(s) exceeds the permit limit of {permitLimit}.");
            }
    
            if (permitCount < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(permitCount), permitCount, $"{nameof(permitCount)} must be greater than or equal to 0.");
            }
        }
    
        private RedisValue[] BuildArgs(int permitCount) => [_maxBurst, _requestsPerPeriod, _periodMs, permitCount];
    
        private IDatabase Connect()
        {
            CheckDisposed();
    
            var store = _store;
            if (store is not null)
            {
                return store;
            }
    
            _connectionLock.Wait();
            try
            {
                store = _store;
                if (store is null)
                {
                    IConnectionMultiplexer connection;
                    if (_options.ConnectionMultiplexerFactory is null)
                    {
                        connection = _options.ConfigurationOptions is not null
                            ? ConnectionMultiplexer.Connect(_options.ConfigurationOptions)
                            : ConnectionMultiplexer.Connect(_options.Configuration!);
                    }
                    else
                    {
                        connection = _options.ConnectionMultiplexerFactory().GetAwaiter().GetResult();
                    }
    
                    PrepareConnection(connection);
                    store = _store = connection.GetDatabase();
                }
    
                Debug.Assert(_store is not null);
                return store;
            }
            finally
            {
                _connectionLock.Release();
            }
        }
    
        private async ValueTask<IDatabase> ConnectAsync(CancellationToken cancellationToken = default)
        {
            CheckDisposed();
            cancellationToken.ThrowIfCancellationRequested();
    
            var store = _store;
            if (store is not null)
            {
                return store;
            }
    
            await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
            try
            {
                store = _store;
                if (store is null)
                {
                    IConnectionMultiplexer connection;
                    if (_options.ConnectionMultiplexerFactory is null)
                    {
                        connection = _options.ConfigurationOptions is not null
                            ? await ConnectionMultiplexer.ConnectAsync(_options.ConfigurationOptions).ConfigureAwait(false)
                            : await ConnectionMultiplexer.ConnectAsync(_options.Configuration!).ConfigureAwait(false);
                    }
                    else
                    {
                        connection = await _options.ConnectionMultiplexerFactory().ConfigureAwait(false);
                    }
    
                    PrepareConnection(connection);
                    store = _store = connection.GetDatabase();
                }
    
                Debug.Assert(_store is not null);
                return store;
            }
            finally
            {
                _connectionLock.Release();
            }
        }
    
        private static void PrepareConnection(IConnectionMultiplexer connection)
        {
            TryAddLibraryNameSuffix(connection);
        }
    
        private static void TryAddLibraryNameSuffix(IConnectionMultiplexer connection)
        {
            try
            {
                connection.AddLibraryNameSuffix("aspnet");
                connection.AddLibraryNameSuffix("RL");
            }
            catch (Exception exception)
            {
                Debug.WriteLine(exception);
            }
        }
    
        private void CheckDisposed()
        {
            if (_disposed)
            {
                throw new ObjectDisposedException(GetType().FullName);
            }
        }
    
        protected override void Dispose(bool disposing)
        {
            if (!disposing)
            {
                return;
            }
    
            if (_disposed)
            {
                return;
            }
    
            _disposed = true;
            ReleaseConnection(Interlocked.Exchange(ref _store, null));
            _connectionLock.Dispose();
        }
    
        protected override async ValueTask DisposeAsyncCore()
        {
            if (_disposed)
            {
                return;
            }
    
            _disposed = true;
            await ReleaseConnectionAsync(Interlocked.Exchange(ref _store, null)).ConfigureAwait(false);
            _connectionLock.Dispose();
        }
    
        private static void ReleaseConnection(IDatabase? store)
        {
            var connection = store?.Multiplexer;
            if (connection is null)
            {
                return;
            }
    
            try
            {
                connection.Close();
                connection.Dispose();
            }
            catch (Exception exception)
            {
                Debug.WriteLine(exception);
            }
        }
    
        private static async ValueTask ReleaseConnectionAsync(IDatabase? store)
        {
            var connection = store?.Multiplexer;
            if (connection is null)
            {
                return;
            }
    
            try
            {
                await connection.CloseAsync().ConfigureAwait(false);
                await connection.DisposeAsync().ConfigureAwait(false);
            }
            catch (Exception exception)
            {
                Debug.WriteLine(exception);
            }
        }
    
        private static RateLimitLease BuildLease(RedisResult result)
        {
            // { allowed, remaining, retry_after_ms, reset_after_ms }
            var values = (RedisResult[])result!;
            var allowed = (long)values[0] == 1;
    
            if (allowed)
                return new RedisGcraRateLimitLease(true, null);
    
            long retryAfterMs = (long)values[2];
            return new RedisGcraRateLimitLease(false, TimeSpan.FromMilliseconds(retryAfterMs));
        }
    }
  16. DeagleGross commented on Jul 2, 2026

    @DeagleGross
    Member

    @lqlive hey, thanks for sharing this prototype.

    I think we should create a package like Microsoft.AspNetCore.RateLimiting.StackExchangeRedis which depends on https://github.com/StackExchange/stackexchange.redis. As Marc has pointed out GCRA will appear later in Redis, and he will support it in StackExchangeRedis - if that happens, it will be much easier to just reuse API from NuGet package than building from scratch implementation in aspnetcore.

    However, nobody is stopping you from proposing general API that should exist in Microsoft.AspNetCore.RateLimiting.StackExchangeRedis - the API should be generic enough to support not only GCRA, but other rate-limiting algorithms as well (theoretically). Then we can pick algorithms one-by-one and include them in the package.

    Are you specifically interested in GCRA? Please let us know about the scenario you are trying to build the solution for. Thanks!

  17. lqlive commented on Jul 2, 2026

    @lqlive
    Contributor

    I think we should create a package like Microsoft.AspNetCore.RateLimiting.StackExchangeRedis which depends on https://github.com/StackExchange/stackexchange.redis. As Marc has pointed out GCRA will appear later in Redis, and he will support it in StackExchangeRedis - if that happens, it will be much easier to just reuse API from NuGet package than building from scratch implementation in aspnetcore.

    One thing I’m wondering about is Redis server version compatibility. If native GCRA support is only available in newer Redis server versions, users running older Redis versions may not be able to use a Redis-backed GCRA limiter if the implementation depends only on the native command.

    Would it make sense for the package/API to allow a Lua-script-based implementation as a compatibility path, at least until the native Redis command is broadly available? That could let the API stay aligned with a future native-command-based implementation while still supporting users on older Redis versions.

    Are you specifically interested in GCRA? Please let us know about the scenario you are trying to build the solution for.

    Yes, I’m specifically interested in GCRA for a multi-tenant SaaS scenario.

    The main scenario is distributed rate limiting across multiple ASP.NET Core instances, where limits are partitioned by tenant and all instances need to share limiter state through Redis. The same mechanism could also apply to user-, client-, IP-, or endpoint-level limits depending on how the partition key is chosen.

    GCRA is interesting here because it provides smoother request pacing than fixed-window counters, supports controlled bursts, requires only small per-partition state in Redis, and can provide a meaningful retry-after value when a request is throttled.

    That said, I’m not trying to make the package GCRA-only. I agree that the package/API should be generic enough to support multiple Redis-backed rate limiting algorithms over time.

    Thanks for taking a look and for the guidance.

  18. DeagleGross commented on Jul 8, 2026

    @DeagleGross
    Member

    Thanks @lqlive!

    One thing I’m wondering about is Redis server version compatibility. If native GCRA support is only available in newer Redis server versions, users running older Redis versions may not be able to use a Redis-backed GCRA limiter if the implementation depends only on the native command.

    Agree. It's the decision we can make altogether - either we support the same algorithm "from scratch" (e.g. similar to your LUA scripting), or we throw if Redis does not support it via official built-in API. We have a precedent - RedisOutputCacheStore today checks the version and then calls the API or invokes the script:

    if (_use62Features)
    {
    await cache.SortedSetAddAsync(_tagMasterKey, tag, expiryTimestamp, SortedSetWhen.GreaterThan, TagCommandFlags).ConfigureAwait(false);
    }
    else
    {
    // semantic equivalent of ZADD GT
    const string ZADD_GT = """
    local oldScore = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[2]))
    if oldScore == nil or oldScore < tonumber(ARGV[1]) then
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
    end
    """;
    // note we're not sharing an ARGV array between tags here because then we'd need to wait on latency to avoid conflicts;
    // in reality most caches have very limited tags (if any), so this is not perceived as an issue
    await cache.ScriptEvaluateAsync(ZADD_GT, _tagMasterKeyArray, new RedisValue[] { expiryTimestamp, tag }, TagCommandFlags).ConfigureAwait(false);
    }

    So probably LUA-script-based implementation makes sense.


    GCRA is interesting here because it provides smoother request pacing than fixed-window counters, supports controlled bursts, requires only small per-partition state in Redis, and can provide a meaningful retry-after value when a request is throttled.

    Totally, I agree that GCRA is a popular algorithm lately due to its usage convenience. I think if you can write up an API proposal - that would be a great next step.

    To be on same page: this effort might not make into net11 due to we are in preview-6 already.

  19. lqlive commented on Jul 10, 2026

    @lqlive
  20. DeagleGross commented on Jul 15, 2026

    @DeagleGross
    Member

    @lqlive thanks for working on this, and sending the code - I think what we need now is only the API proposal - specifically what new Public API we want to expose, and why (without inner implementation).

    See examples:

    Our process involves firstly looking into what new API should and should not exist (only signatures), and only then we can start with the actual PR review. But you can always attach the draft PR with the prototype to show how the API works under the hood (which is cheaper now due to AI).

  21. lqlive commented on Jul 15, 2026

    @lqlive
    Contributor

    @DeagleGross got it, my misunderstanding. I'll have the API proposal ready this week. Thanks!

  22. added this to the .NET 12 Planning milestone on Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area-middlewareIncludes: URL rewrite, redirect, response cache/compression, session, and other general middlewaresdesign-proposalThis issue represents a design proposal for a different issue, linked in the descriptionfeature-rate-limitWork related to use of rate limit primitives

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions