diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs
index 6bc42dd14..86f566b61 100644
--- a/src/RESPite/Messages/RespReader.cs
+++ b/src/RESPite/Messages/RespReader.cs
@@ -1852,9 +1852,11 @@ public readonly decimal ReadDecimal()
}
///
- /// Read the current element as a value.
+ /// Try to read the current element as a value.
///
- public readonly bool ReadBoolean()
+ /// The parsed boolean value if successful.
+ /// True if the value was successfully parsed; false otherwise.
+ public readonly bool TryReadBoolean(out bool value)
{
var span = Buffer(stackalloc byte[2]);
switch (span.Length)
@@ -1862,20 +1864,42 @@ public readonly bool ReadBoolean()
case 1:
switch (span[0])
{
- case (byte)'0' when Prefix == RespPrefix.Integer: return false;
- case (byte)'1' when Prefix == RespPrefix.Integer: return true;
- case (byte)'f' when Prefix == RespPrefix.Boolean: return false;
- case (byte)'t' when Prefix == RespPrefix.Boolean: return true;
+ case (byte)'0' when Prefix == RespPrefix.Integer:
+ value = false;
+ return true;
+ case (byte)'1' when Prefix == RespPrefix.Integer:
+ value = true;
+ return true;
+ case (byte)'f' when Prefix == RespPrefix.Boolean:
+ value = false;
+ return true;
+ case (byte)'t' when Prefix == RespPrefix.Boolean:
+ value = true;
+ return true;
}
break;
- case 2 when Prefix == RespPrefix.SimpleString && IsOK(): return true;
+ case 2 when Prefix == RespPrefix.SimpleString && IsOK():
+ value = true;
+ return true;
}
- ThrowFormatException();
+ value = false;
return false;
}
+ ///
+ /// Read the current element as a value.
+ ///
+ public readonly bool ReadBoolean()
+ {
+ if (!TryReadBoolean(out var value))
+ {
+ ThrowFormatException();
+ }
+ return value;
+ }
+
///
/// Parse a scalar value as an enum of type .
///
diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt
index 9ce6685bc..3dbd5e4df 100644
--- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt
@@ -155,6 +155,7 @@
[SER004]RESPite.Messages.RespReader.ProtocolBytesRemaining.get -> long
[SER004]RESPite.Messages.RespReader.ReadArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]?
[SER004]RESPite.Messages.RespReader.ReadBoolean() -> bool
+[SER004]RESPite.Messages.RespReader.TryReadBoolean(out bool value) -> bool
[SER004]RESPite.Messages.RespReader.ReadByteArray() -> byte[]?
[SER004]RESPite.Messages.RespReader.ReadDecimal() -> decimal
[SER004]RESPite.Messages.RespReader.ReadDouble() -> double
diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs
index e9abf3167..aa0d8f1c1 100644
--- a/src/StackExchange.Redis/Enums/RedisCommand.cs
+++ b/src/StackExchange.Redis/Enums/RedisCommand.cs
@@ -59,6 +59,7 @@ internal enum RedisCommand
GEOSEARCH,
GEOSEARCHSTORE,
+ GCRA,
GET,
GETBIT,
GETDEL,
@@ -318,6 +319,7 @@ internal static bool IsPrimaryOnly(this RedisCommand command)
case RedisCommand.EXPIREAT:
case RedisCommand.FLUSHALL:
case RedisCommand.FLUSHDB:
+ case RedisCommand.GCRA:
case RedisCommand.GEOSEARCHSTORE:
case RedisCommand.GETDEL:
case RedisCommand.GETEX:
diff --git a/src/StackExchange.Redis/ExtensionMethods.cs b/src/StackExchange.Redis/ExtensionMethods.cs
index 240a8752b..4bac65e4b 100644
--- a/src/StackExchange.Redis/ExtensionMethods.cs
+++ b/src/StackExchange.Redis/ExtensionMethods.cs
@@ -7,6 +7,7 @@
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
namespace StackExchange.Redis
@@ -328,5 +329,69 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan span)
return -1;
}
#endif
+
+ ///
+ /// Attempts to acquire a GCRA rate limit token, retrying with delays if rate limited.
+ ///
+ /// The database instance.
+ /// The key for the rate limiter.
+ /// The maximum burst size.
+ /// The number of requests allowed per period.
+ /// The maximum time to wait for a successful acquisition.
+ /// The period in seconds (default: 1.0).
+ /// The number of tokens to acquire (default: 1).
+ /// The command flags to use.
+ /// The cancellation token.
+ /// True if the token was acquired within the allowed time; false otherwise.
+ public static async ValueTask TryAcquireGcraAsync(
+ this IDatabaseAsync database,
+ RedisKey key,
+ int maxBurst,
+ int requestsPerPeriod,
+ TimeSpan allow,
+ double periodSeconds = 1.0,
+ int count = 1,
+ CommandFlags flags = CommandFlags.None,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var startTime = DateTime.UtcNow;
+ var allowMilliseconds = allow.TotalMilliseconds;
+
+ while (true)
+ {
+ var result = await database.StringGcraRateLimitAsync(key, maxBurst, requestsPerPeriod, periodSeconds, count, flags).ConfigureAwait(false);
+
+ if (!result.Limited)
+ {
+ return true;
+ }
+
+ var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
+ var remaining = allowMilliseconds - elapsed;
+
+ if (remaining <= 0)
+ {
+ return false;
+ }
+
+ var delaySeconds = result.RetryAfterSeconds;
+ if (delaySeconds <= 0)
+ {
+ // Shouldn't happen when Limited is true, but handle defensively
+ return false;
+ }
+
+ var delayMilliseconds = delaySeconds * 1000.0;
+ if (delayMilliseconds > remaining)
+ {
+ // Not enough time left to wait for retry
+ return false;
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken).ConfigureAwait(false);
+ }
+ }
}
}
diff --git a/src/StackExchange.Redis/Gcra.GcraMessage.cs b/src/StackExchange.Redis/Gcra.GcraMessage.cs
new file mode 100644
index 000000000..7207e6acd
--- /dev/null
+++ b/src/StackExchange.Redis/Gcra.GcraMessage.cs
@@ -0,0 +1,40 @@
+namespace StackExchange.Redis;
+
+internal partial class RedisDatabase
+{
+ internal sealed class GcraMessage(
+ int database,
+ CommandFlags flags,
+ RedisKey key,
+ int maxBurst,
+ int requestsPerPeriod,
+ double periodSeconds,
+ int count) : Message(database, flags, RedisCommand.GCRA)
+ {
+ protected override void WriteImpl(in MessageWriter writer)
+ {
+ // GCRA key max_burst requests_per_period period [NUM_REQUESTS count]
+ writer.WriteHeader(Command, ArgCount);
+ writer.WriteBulkString(key);
+ writer.WriteBulkString(maxBurst);
+ writer.WriteBulkString(requestsPerPeriod);
+ writer.WriteBulkString(periodSeconds);
+
+ if (count != 1)
+ {
+ writer.WriteBulkString("NUM_REQUESTS"u8);
+ writer.WriteBulkString(count);
+ }
+ }
+
+ public override int ArgCount
+ {
+ get
+ {
+ int argCount = 4; // key, max_burst, requests_per_period, period
+ if (count != 1) argCount += 2; // NUM_REQUESTS, count
+ return argCount;
+ }
+ }
+ }
+}
diff --git a/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs b/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs
new file mode 100644
index 000000000..bea6944e1
--- /dev/null
+++ b/src/StackExchange.Redis/Gcra.GcraRateLimitResult.cs
@@ -0,0 +1,45 @@
+namespace StackExchange.Redis;
+
+///
+/// Represents the result of a GCRA (Generic Cell Rate Algorithm) rate limit check.
+///
+public readonly partial struct GcraRateLimitResult
+{
+ ///
+ /// Indicates whether the request was rate limited (true) or allowed (false).
+ ///
+ public bool Limited { get; }
+
+ ///
+ /// The maximum number of requests allowed. Always equal to max_burst + 1.
+ ///
+ public int MaxRequests { get; }
+
+ ///
+ /// The number of requests available immediately without being rate limited.
+ ///
+ public int AvailableRequests { get; }
+
+ ///
+ /// The number of seconds after which the caller should retry.
+ /// Returns -1 if the request is not limited.
+ ///
+ public int RetryAfterSeconds { get; }
+
+ ///
+ /// The number of seconds after which a full burst will be allowed.
+ ///
+ public int FullBurstAfterSeconds { get; }
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ public GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds)
+ {
+ Limited = limited;
+ MaxRequests = maxRequests;
+ AvailableRequests = availableRequests;
+ RetryAfterSeconds = retryAfterSeconds;
+ FullBurstAfterSeconds = fullBurstAfterSeconds;
+ }
+}
diff --git a/src/StackExchange.Redis/Gcra.ResultProcessor.cs b/src/StackExchange.Redis/Gcra.ResultProcessor.cs
new file mode 100644
index 000000000..f024f0344
--- /dev/null
+++ b/src/StackExchange.Redis/Gcra.ResultProcessor.cs
@@ -0,0 +1,39 @@
+using RESPite.Messages;
+
+namespace StackExchange.Redis;
+
+public readonly partial struct GcraRateLimitResult
+{
+ internal static readonly ResultProcessor Processor = new GcraRateLimitResultProcessor();
+
+ private sealed class GcraRateLimitResultProcessor : ResultProcessor
+ {
+ protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader)
+ {
+ // GCRA returns an array with 5 elements:
+ // 1) # 0 or 1
+ // 2) # max number of request. Always equal to max_burst+1
+ // 3) # number of requests available immediately
+ // 4) # number of seconds after which caller should retry. Always returns -1 if request isn't limited.
+ // 5) # number of seconds after which a full burst will be allowed
+ if (reader.IsAggregate
+ && reader.TryMoveNext() && reader.IsScalar && reader.TryReadBoolean(out bool limited)
+ && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long maxRequests)
+ && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long availableRequests)
+ && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long retryAfterSeconds)
+ && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out long fullBurstAfterSeconds))
+ {
+ var result = new GcraRateLimitResult(
+ limited: limited,
+ maxRequests: (int)maxRequests,
+ availableRequests: (int)availableRequests,
+ retryAfterSeconds: (int)retryAfterSeconds,
+ fullBurstAfterSeconds: (int)fullBurstAfterSeconds);
+ SetResult(message, result);
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs
index 10b2d6357..dd3e1c133 100644
--- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs
+++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs
@@ -56,10 +56,11 @@ private HotKeysResult(ref RespReader reader)
if (!reader.TryMoveNext()) break;
long i64;
+ bool b;
switch (field)
{
- case HotKeysField.TrackingActive:
- TrackingActive = reader.ReadBoolean();
+ case HotKeysField.TrackingActive when reader.TryReadBoolean(out b):
+ TrackingActive = b;
break;
case HotKeysField.SampleRatio when reader.TryReadInt64(out i64):
SampleRatio = i64;
diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs
index e26154652..021a8ad5e 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabase.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs
@@ -3254,6 +3254,19 @@ IEnumerable SortedSetScan(
[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)]
ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None);
+ ///
+ /// Performs a GCRA (Generic Cell Rate Algorithm) rate limit check on the specified key.
+ ///
+ /// The key to rate limit.
+ /// The maximum burst size.
+ /// The number of requests allowed per period.
+ /// The period duration in seconds. Default is 1.0.
+ /// The number of requests to consume. Default is 1.
+ /// The flags to use for this operation.
+ /// A containing the rate limit decision and metadata.
+ ///
+ GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None);
+
///
/// Get the value of key. If the key does not exist the special value is returned.
/// An error is returned if the value stored at key is not a string, because GET only handles string values.
diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
index 9719861d8..f7c3d703a 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
@@ -795,6 +795,9 @@ IAsyncEnumerable SortedSetScanAsync(
[Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)]
Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None);
+ ///
+ Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None);
+
///
Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
index c7831fdb8..d3314ab7a 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs
@@ -792,6 +792,9 @@ public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlag
public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) =>
Inner.StringLengthAsync(ToInner(key), flags);
+ public Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double period = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) =>
+ Inner.StringGcraRateLimitAsync(ToInner(key), maxBurst, requestsPerPeriod, period, count, flags);
+
public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None)
=> Inner.StringSetAsync(ToInner(key), value, expiry, when, flags);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
index 498159578..7b98255a3 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs
@@ -774,6 +774,9 @@ public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = C
public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) =>
Inner.StringLength(ToInner(key), flags);
+ public GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double period = 1.0, int count = 1, CommandFlags flags = CommandFlags.None) =>
+ Inner.StringGcraRateLimit(ToInner(key), maxBurst, requestsPerPeriod, period, count, flags);
+
public bool StringSet(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None)
=> Inner.StringSet(ToInner(key), value, expiry, when, flags);
diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs
index 9c975d03b..be76221e5 100644
--- a/src/StackExchange.Redis/PhysicalBridge.cs
+++ b/src/StackExchange.Redis/PhysicalBridge.cs
@@ -1615,7 +1615,8 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne
if (_nextHighIntegrityToken is not 0
&& !connection.TransactionActive // validated in the UNWATCH/EXEC/DISCARD
- && message.Command is not RedisCommand.AUTH or RedisCommand.HELLO) // if auth fails, ECHO may also fail; avoid confusion
+ && message.Command is not RedisCommand.AUTH // if auth fails, ECHO may also fail; avoid confusion
+ && message.Command is not RedisCommand.HELLO)
{
// make sure this value exists early to avoid a race condition
// if the response comes back super quickly
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
index b2f5cab2c..32609a8a4 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
@@ -481,6 +481,14 @@ StackExchange.Redis.GeoUnit.Feet = 3 -> StackExchange.Redis.GeoUnit
StackExchange.Redis.GeoUnit.Kilometers = 1 -> StackExchange.Redis.GeoUnit
StackExchange.Redis.GeoUnit.Meters = 0 -> StackExchange.Redis.GeoUnit
StackExchange.Redis.GeoUnit.Miles = 2 -> StackExchange.Redis.GeoUnit
+StackExchange.Redis.GcraRateLimitResult
+StackExchange.Redis.GcraRateLimitResult.AvailableRequests.get -> int
+StackExchange.Redis.GcraRateLimitResult.FullBurstAfterSeconds.get -> int
+StackExchange.Redis.GcraRateLimitResult.GcraRateLimitResult() -> void
+StackExchange.Redis.GcraRateLimitResult.GcraRateLimitResult(bool limited, int maxRequests, int availableRequests, int retryAfterSeconds, int fullBurstAfterSeconds) -> void
+StackExchange.Redis.GcraRateLimitResult.Limited.get -> bool
+StackExchange.Redis.GcraRateLimitResult.MaxRequests.get -> int
+StackExchange.Redis.GcraRateLimitResult.RetryAfterSeconds.get -> int
StackExchange.Redis.HashEntry
StackExchange.Redis.HashEntry.Equals(StackExchange.Redis.HashEntry other) -> bool
StackExchange.Redis.HashEntry.HashEntry() -> void
@@ -777,6 +785,7 @@ StackExchange.Redis.IDatabase.StringGetSet(StackExchange.Redis.RedisKey key, Sta
StackExchange.Redis.IDatabase.StringGetSetExpiry(StackExchange.Redis.RedisKey key, System.TimeSpan? expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue
StackExchange.Redis.IDatabase.StringGetSetExpiry(StackExchange.Redis.RedisKey key, System.DateTime expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue
StackExchange.Redis.IDatabase.StringGetWithExpiry(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValueWithExpiry
+StackExchange.Redis.IDatabase.StringGcraRateLimit(StackExchange.Redis.RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1, int count = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.GcraRateLimitResult
StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> double
StackExchange.Redis.IDatabase.StringIncrement(StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long
StackExchange.Redis.IDatabase.StringLength(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long
@@ -1021,6 +1030,7 @@ StackExchange.Redis.IDatabaseAsync.StringGetSetAsync(StackExchange.Redis.RedisKe
StackExchange.Redis.IDatabaseAsync.StringGetSetExpiryAsync(StackExchange.Redis.RedisKey key, System.TimeSpan? expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.StringGetSetExpiryAsync(StackExchange.Redis.RedisKey key, System.DateTime expiry, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.StringGetWithExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
+StackExchange.Redis.IDatabaseAsync.StringGcraRateLimitAsync(StackExchange.Redis.RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1, int count = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, double value, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.StringIncrementAsync(StackExchange.Redis.RedisKey key, long value = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.StringLengthAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
@@ -1710,6 +1720,7 @@ static StackExchange.Redis.ExtensionMethods.ToStringArray(this StackExchange.Red
static StackExchange.Redis.ExtensionMethods.ToStringDictionary(this StackExchange.Redis.HashEntry[]? hash) -> System.Collections.Generic.Dictionary?
static StackExchange.Redis.ExtensionMethods.ToStringDictionary(this StackExchange.Redis.SortedSetEntry[]? sortedSet) -> System.Collections.Generic.Dictionary?
static StackExchange.Redis.ExtensionMethods.ToStringDictionary(this System.Collections.Generic.KeyValuePair[]? pairs) -> System.Collections.Generic.Dictionary?
+static StackExchange.Redis.ExtensionMethods.TryAcquireGcraAsync(this StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key, int maxBurst, int requestsPerPeriod, System.TimeSpan allow, double periodSeconds = 1, int count = 1, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
static StackExchange.Redis.GeoEntry.operator !=(StackExchange.Redis.GeoEntry x, StackExchange.Redis.GeoEntry y) -> bool
static StackExchange.Redis.GeoEntry.operator ==(StackExchange.Redis.GeoEntry x, StackExchange.Redis.GeoEntry y) -> bool
static StackExchange.Redis.GeoPosition.operator !=(StackExchange.Redis.GeoPosition x, StackExchange.Redis.GeoPosition y) -> bool
diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs
index dfe4c7259..6dca21915 100644
--- a/src/StackExchange.Redis/RedisDatabase.Strings.cs
+++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs
@@ -47,6 +47,18 @@ private Message GetStringDeleteMessage(in RedisKey key, in ValueCondition when,
return ExecuteAsync(msg, ResultProcessor.Digest);
}
+ public GcraRateLimitResult StringGcraRateLimit(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = new GcraMessage(Database, flags, key, maxBurst, requestsPerPeriod, periodSeconds, count);
+ return ExecuteSync(msg, ResultProcessor.GcraRateLimit);
+ }
+
+ public Task StringGcraRateLimitAsync(RedisKey key, int maxBurst, int requestsPerPeriod, double periodSeconds = 1.0, int count = 1, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = new GcraMessage(Database, flags, key, maxBurst, requestsPerPeriod, periodSeconds, count);
+ return ExecuteAsync(msg, ResultProcessor.GcraRateLimit);
+ }
+
public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry, ValueCondition when, CommandFlags flags = CommandFlags.None)
{
var msg = GetStringSetMessage(key, value, expiry, when, flags);
diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs
index c7773879d..8753b3239 100644
--- a/src/StackExchange.Redis/RedisLiterals.cs
+++ b/src/StackExchange.Redis/RedisLiterals.cs
@@ -78,6 +78,7 @@ public static readonly RedisValue
NOSAVE = RedisValue.FromRaw("NOSAVE"u8),
NOT = RedisValue.FromRaw("NOT"u8),
NOVALUES = RedisValue.FromRaw("NOVALUES"u8),
+ NUM_REQUESTS = RedisValue.FromRaw("NUM_REQUESTS"u8),
NUMPAT = RedisValue.FromRaw("NUMPAT"u8),
NUMSUB = RedisValue.FromRaw("NUMSUB"u8),
NX = RedisValue.FromRaw("NX"u8),
diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs
index b476708cc..6038d65c2 100644
--- a/src/StackExchange.Redis/ResultProcessor.cs
+++ b/src/StackExchange.Redis/ResultProcessor.cs
@@ -113,6 +113,9 @@ public static readonly ResultProcessor
public static readonly ResultProcessor
RedisGeoPosition = new RedisValueGeoPositionProcessor();
+ public static readonly ResultProcessor
+ GcraRateLimit = GcraRateLimitResult.Processor;
+
public static readonly ResultProcessor
ResponseTimer = new TimingProcessor();
diff --git a/tests/StackExchange.Redis.Tests/GcraTestServer.cs b/tests/StackExchange.Redis.Tests/GcraTestServer.cs
new file mode 100644
index 000000000..cbccce795
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/GcraTestServer.cs
@@ -0,0 +1,87 @@
+using RESPite;
+using RESPite.Messages;
+using StackExchange.Redis.Server;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Test Redis server that simulates GCRA rate limiting responses.
+///
+public class GcraTestServer : InProcessTestServer
+{
+ private readonly GcraRateLimitResult _expectedResult;
+ private GcraRequestSnapshot? _lastRequest;
+
+ public GcraTestServer(GcraRateLimitResult expectedResult, ITestOutputHelper? log = null) : base(log)
+ {
+ _expectedResult = expectedResult;
+ }
+
+ ///
+ /// Snapshot of the last GCRA request received by the server.
+ ///
+ public sealed class GcraRequestSnapshot
+ {
+ public RedisKey Key { get; init; }
+ public int MaxBurst { get; init; }
+ public int RequestsPerPeriod { get; init; }
+ public double PeriodSeconds { get; init; }
+ public int Count { get; init; }
+ }
+
+ ///
+ /// Gets the last GCRA request received by the server.
+ ///
+ public GcraRequestSnapshot? LastRequest => _lastRequest;
+
+ ///
+ /// Handles GCRA commands. Returns the configured result and captures request parameters.
+ ///
+ [RedisCommand(-5, "GCRA")]
+ protected virtual TypedRedisValue Gcra(RedisClient client, in RedisRequest request)
+ {
+ // Parse request parameters
+ var key = request.GetKey(1);
+ var maxBurst = request.GetInt32(2);
+ var requestsPerPeriod = request.GetInt32(3);
+ // Parse period as a string and convert to double
+ var periodString = request.GetString(4);
+ var periodSeconds = double.Parse(periodString, System.Globalization.CultureInfo.InvariantCulture);
+
+ // Optional count parameter (defaults to 1)
+ var count = 1;
+ if (request.Count >= 7 && request.GetString(5) == "NUM_REQUESTS")
+ {
+ count = request.GetInt32(6);
+ }
+
+ // Capture the request
+ _lastRequest = new GcraRequestSnapshot
+ {
+ Key = key,
+ MaxBurst = maxBurst,
+ RequestsPerPeriod = requestsPerPeriod,
+ PeriodSeconds = periodSeconds,
+ Count = count,
+ };
+
+ // Return the configured result as a 5-element array
+ var result = TypedRedisValue.Rent(5, out var span, RespPrefix.Array);
+ span[0] = TypedRedisValue.Integer(_expectedResult.Limited ? 1 : 0);
+ span[1] = TypedRedisValue.Integer(_expectedResult.MaxRequests);
+ span[2] = TypedRedisValue.Integer(_expectedResult.AvailableRequests);
+ span[3] = TypedRedisValue.Integer(_expectedResult.RetryAfterSeconds);
+ span[4] = TypedRedisValue.Integer(_expectedResult.FullBurstAfterSeconds);
+ return result;
+ }
+
+ ///
+ /// Resets the last request snapshot.
+ ///
+ public override void ResetCounters()
+ {
+ _lastRequest = null;
+ base.ResetCounters();
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/GcraUnitTests.cs b/tests/StackExchange.Redis.Tests/GcraUnitTests.cs
new file mode 100644
index 000000000..fe24939a6
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/GcraUnitTests.cs
@@ -0,0 +1,155 @@
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Unit tests for GCRA rate limiting functionality.
+///
+public class GcraUnitTests(ITestOutputHelper log)
+{
+ private RedisKey Me([CallerMemberName] string callerName = "") => callerName;
+
+ [Fact]
+ public async Task GcraRateLimit_NotLimited_ReturnsExpectedResult()
+ {
+ // Arrange
+ var expectedResult = new GcraRateLimitResult(
+ limited: false,
+ maxRequests: 10,
+ availableRequests: 9,
+ retryAfterSeconds: 0,
+ fullBurstAfterSeconds: 1);
+
+ using var server = new GcraTestServer(expectedResult, log);
+ await using var muxer = await server.ConnectAsync();
+ var db = muxer.GetDatabase();
+ var key = Me();
+
+ // Act
+ var result = await db.StringGcraRateLimitAsync(key, maxBurst: 10, requestsPerPeriod: 10, periodSeconds: 1.0, count: 1);
+
+ // Assert
+ Assert.False(result.Limited);
+ Assert.Equal(10, result.MaxRequests);
+ Assert.Equal(9, result.AvailableRequests);
+ Assert.Equal(0, result.RetryAfterSeconds);
+ Assert.Equal(1, result.FullBurstAfterSeconds);
+
+ // Verify the request received by the server
+ var lastRequest = server.LastRequest;
+ Assert.NotNull(lastRequest);
+ Assert.Equal(key, lastRequest.Key);
+ Assert.Equal(10, lastRequest.MaxBurst);
+ Assert.Equal(10, lastRequest.RequestsPerPeriod);
+ Assert.Equal(1.0, lastRequest.PeriodSeconds);
+ Assert.Equal(1, lastRequest.Count);
+ }
+
+ [Fact]
+ public async Task GcraRateLimit_Limited_ReturnsExpectedResult()
+ {
+ // Arrange
+ var expectedResult = new GcraRateLimitResult(
+ limited: true,
+ maxRequests: 5,
+ availableRequests: 0,
+ retryAfterSeconds: 2,
+ fullBurstAfterSeconds: 10);
+
+ using var server = new GcraTestServer(expectedResult, log);
+ await using var muxer = await server.ConnectAsync();
+ var db = muxer.GetDatabase();
+ var key = Me();
+
+ // Act
+ var result = await db.StringGcraRateLimitAsync(key, maxBurst: 5, requestsPerPeriod: 5, periodSeconds: 1.0);
+
+ // Assert
+ Assert.True(result.Limited);
+ Assert.Equal(5, result.MaxRequests);
+ Assert.Equal(0, result.AvailableRequests);
+ Assert.Equal(2, result.RetryAfterSeconds);
+ Assert.Equal(10, result.FullBurstAfterSeconds);
+
+ // Verify the request received by the server
+ var lastRequest = server.LastRequest;
+ Assert.NotNull(lastRequest);
+ Assert.Equal(key, lastRequest.Key);
+ Assert.Equal(5, lastRequest.MaxBurst);
+ Assert.Equal(5, lastRequest.RequestsPerPeriod);
+ Assert.Equal(1.0, lastRequest.PeriodSeconds);
+ Assert.Equal(1, lastRequest.Count);
+ }
+
+ [Fact]
+ public async Task GcraRateLimit_WithCustomCount_SendsCorrectParameters()
+ {
+ // Arrange
+ var expectedResult = new GcraRateLimitResult(
+ limited: false,
+ maxRequests: 100,
+ availableRequests: 95,
+ retryAfterSeconds: 0,
+ fullBurstAfterSeconds: 5);
+
+ using var server = new GcraTestServer(expectedResult, log);
+ await using var muxer = await server.ConnectAsync();
+ var db = muxer.GetDatabase();
+ var key = Me();
+
+ // Act
+ var result = await db.StringGcraRateLimitAsync(key, maxBurst: 100, requestsPerPeriod: 100, periodSeconds: 60.0, count: 5);
+
+ // Assert
+ Assert.False(result.Limited);
+ Assert.Equal(100, result.MaxRequests);
+ Assert.Equal(95, result.AvailableRequests);
+
+ // Verify the request received by the server includes the count parameter
+ var lastRequest = server.LastRequest;
+ Assert.NotNull(lastRequest);
+ Assert.Equal(key, lastRequest.Key);
+ Assert.Equal(100, lastRequest.MaxBurst);
+ Assert.Equal(100, lastRequest.RequestsPerPeriod);
+ Assert.Equal(60.0, lastRequest.PeriodSeconds);
+ Assert.Equal(5, lastRequest.Count);
+ }
+
+ [Fact]
+ public async Task GcraRateLimit_SyncVersion_ReturnsExpectedResult()
+ {
+ // Arrange
+ var expectedResult = new GcraRateLimitResult(
+ limited: false,
+ maxRequests: 20,
+ availableRequests: 19,
+ retryAfterSeconds: 0,
+ fullBurstAfterSeconds: 1);
+
+ using var server = new GcraTestServer(expectedResult, log);
+ await using var muxer = await server.ConnectAsync();
+ var db = muxer.GetDatabase();
+ var key = Me();
+
+ // Act
+ var result = db.StringGcraRateLimit(key, maxBurst: 20, requestsPerPeriod: 20, periodSeconds: 1.0);
+
+ // Assert
+ Assert.False(result.Limited);
+ Assert.Equal(20, result.MaxRequests);
+ Assert.Equal(19, result.AvailableRequests);
+ Assert.Equal(0, result.RetryAfterSeconds);
+ Assert.Equal(1, result.FullBurstAfterSeconds);
+
+ // Verify the request received by the server
+ var lastRequest = server.LastRequest;
+ Assert.NotNull(lastRequest);
+ Assert.Equal(key, lastRequest.Key);
+ Assert.Equal(20, lastRequest.MaxBurst);
+ Assert.Equal(20, lastRequest.RequestsPerPeriod);
+ Assert.Equal(1.0, lastRequest.PeriodSeconds);
+ Assert.Equal(1, lastRequest.Count);
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs
new file mode 100644
index 000000000..8a05768d6
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GcraRateLimit.cs
@@ -0,0 +1,78 @@
+using Xunit;
+
+namespace StackExchange.Redis.Tests.ResultProcessorUnitTests;
+
+///
+/// Tests for GCRA rate limit result processor.
+///
+public class GcraRateLimit(ITestOutputHelper log) : ResultProcessorUnitTest(log)
+{
+ [Fact]
+ public void GcraRateLimit_NotLimited_Success()
+ {
+ // GCRA response when request is allowed:
+ // 1) 0 (not limited)
+ // 2) 11 (max requests = max_burst + 1)
+ // 3) 10 (available requests)
+ // 4) -1 (retry after - always -1 when not limited)
+ // 5) 5 (full burst after seconds)
+ var resp = "*5\r\n:0\r\n:11\r\n:10\r\n:-1\r\n:5\r\n";
+ var processor = ResultProcessor.GcraRateLimit;
+ var result = Execute(resp, processor);
+
+ Assert.False(result.Limited);
+ Assert.Equal(11, result.MaxRequests);
+ Assert.Equal(10, result.AvailableRequests);
+ Assert.Equal(-1, result.RetryAfterSeconds);
+ Assert.Equal(5, result.FullBurstAfterSeconds);
+ }
+
+ [Fact]
+ public void GcraRateLimit_Limited_Success()
+ {
+ // GCRA response when request is rate limited:
+ // 1) 1 (limited)
+ // 2) 11 (max requests = max_burst + 1)
+ // 3) 0 (no available requests)
+ // 4) 2 (retry after 2 seconds)
+ // 5) 10 (full burst after 10 seconds)
+ var resp = "*5\r\n:1\r\n:11\r\n:0\r\n:2\r\n:10\r\n";
+ var processor = ResultProcessor.GcraRateLimit;
+ var result = Execute(resp, processor);
+
+ Assert.True(result.Limited);
+ Assert.Equal(11, result.MaxRequests);
+ Assert.Equal(0, result.AvailableRequests);
+ Assert.Equal(2, result.RetryAfterSeconds);
+ Assert.Equal(10, result.FullBurstAfterSeconds);
+ }
+
+ [Fact]
+ public void GcraRateLimit_PartiallyAvailable_Success()
+ {
+ // GCRA response when some requests are available:
+ // 1) 0 (not limited)
+ // 2) 101 (max requests)
+ // 3) 50 (50 requests available)
+ // 4) -1 (retry after - not limited)
+ // 5) 100 (full burst after 100 seconds)
+ var resp = "*5\r\n:0\r\n:101\r\n:50\r\n:-1\r\n:100\r\n";
+ var processor = ResultProcessor.GcraRateLimit;
+ var result = Execute(resp, processor);
+
+ Assert.False(result.Limited);
+ Assert.Equal(101, result.MaxRequests);
+ Assert.Equal(50, result.AvailableRequests);
+ Assert.Equal(-1, result.RetryAfterSeconds);
+ Assert.Equal(100, result.FullBurstAfterSeconds);
+ }
+
+ [Theory]
+ [InlineData("*4\r\n:0\r\n:11\r\n:10\r\n:-1\r\n")] // only 4 elements
+ [InlineData(":0\r\n")] // scalar instead of array
+ [InlineData("*5\r\n$1\r\n0\r\n:11\r\n:10\r\n:-1\r\n:5\r\n")] // first element is string
+ public void GcraRateLimit_InvalidResponse_Failure(string resp)
+ {
+ ExecuteUnexpected(resp, ResultProcessor.GcraRateLimit);
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs
new file mode 100644
index 000000000..0d3aecb78
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/GcraRateLimitRoundTrip.cs
@@ -0,0 +1,68 @@
+using System.Threading.Tasks;
+using Xunit;
+
+namespace StackExchange.Redis.Tests.RoundTripUnitTests;
+
+public class GcraRateLimitRoundTrip
+{
+ [Theory(Timeout = 1000)]
+ [InlineData("mykey", 10, 100, 1.0, "*5\r\n$4\r\nGCRA\r\n$5\r\nmykey\r\n$2\r\n10\r\n$3\r\n100\r\n$1\r\n1\r\n", "*5\r\n:0\r\n:11\r\n:10\r\n:-1\r\n:5\r\n")]
+ public async Task GcraRateLimit_DefaultCount_RoundTrip(
+ string key,
+ int maxBurst,
+ int requestsPerPeriod,
+ double periodSeconds,
+ string requestResp,
+ string responseResp)
+ {
+ var msg = new RedisDatabase.GcraMessage(0, CommandFlags.None, key, maxBurst, requestsPerPeriod, periodSeconds, 1);
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp);
+
+ Assert.False(result.Limited);
+ Assert.Equal(11, result.MaxRequests);
+ Assert.Equal(10, result.AvailableRequests);
+ Assert.Equal(-1, result.RetryAfterSeconds);
+ Assert.Equal(5, result.FullBurstAfterSeconds);
+ }
+
+ [Theory(Timeout = 1000)]
+ [InlineData("mykey", 10, 100, 1.0, 5, "*7\r\n$4\r\nGCRA\r\n$5\r\nmykey\r\n$2\r\n10\r\n$3\r\n100\r\n$1\r\n1\r\n$12\r\nNUM_REQUESTS\r\n$1\r\n5\r\n", "*5\r\n:1\r\n:11\r\n:0\r\n:2\r\n:10\r\n")]
+ public async Task GcraRateLimit_WithCount_RoundTrip(
+ string key,
+ int maxBurst,
+ int requestsPerPeriod,
+ double periodSeconds,
+ int count,
+ string requestResp,
+ string responseResp)
+ {
+ var msg = new RedisDatabase.GcraMessage(0, CommandFlags.None, key, maxBurst, requestsPerPeriod, periodSeconds, count);
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp);
+
+ Assert.True(result.Limited);
+ Assert.Equal(11, result.MaxRequests);
+ Assert.Equal(0, result.AvailableRequests);
+ Assert.Equal(2, result.RetryAfterSeconds);
+ Assert.Equal(10, result.FullBurstAfterSeconds);
+ }
+
+ [Theory(Timeout = 1000)]
+ [InlineData("rate:api", 50, 1000, 60.0, "*5\r\n$4\r\nGCRA\r\n$8\r\nrate:api\r\n$2\r\n50\r\n$4\r\n1000\r\n$2\r\n60\r\n", "*5\r\n:0\r\n:51\r\n:25\r\n:-1\r\n:30\r\n")]
+ public async Task GcraRateLimit_CustomPeriod_RoundTrip(
+ string key,
+ int maxBurst,
+ int requestsPerPeriod,
+ double periodSeconds,
+ string requestResp,
+ string responseResp)
+ {
+ var msg = new RedisDatabase.GcraMessage(0, CommandFlags.None, key, maxBurst, requestsPerPeriod, periodSeconds, 1);
+ var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.GcraRateLimit, requestResp, responseResp);
+
+ Assert.False(result.Limited);
+ Assert.Equal(51, result.MaxRequests);
+ Assert.Equal(25, result.AvailableRequests);
+ Assert.Equal(-1, result.RetryAfterSeconds);
+ Assert.Equal(30, result.FullBurstAfterSeconds);
+ }
+}
diff --git a/toys/StackExchange.Redis.Server/TypedRedisValue.cs b/toys/StackExchange.Redis.Server/TypedRedisValue.cs
index 0493399ac..2daa22611 100644
--- a/toys/StackExchange.Redis.Server/TypedRedisValue.cs
+++ b/toys/StackExchange.Redis.Server/TypedRedisValue.cs
@@ -10,9 +10,15 @@ namespace StackExchange.Redis
///
public readonly struct TypedRedisValue
{
- // note: if this ever becomes exposed on the public API, it should be made so that it clears;
- // can't trust external callers to clear the space, and using recycle without that is dangerous
- internal static TypedRedisValue Rent(int count, out Span span, RespPrefix type)
+ ///
+ /// Rents an array from the pool and returns a that wraps it.
+ /// The returned span is cleared to ensure safe usage.
+ ///
+ /// The number of elements to rent.
+ /// The span that can be used to populate the array.
+ /// The RESP type of the array.
+ /// A that wraps the rented array.
+ public static TypedRedisValue Rent(int count, out Span span, RespPrefix type)
{
if (count == 0)
{
@@ -20,8 +26,9 @@ internal static TypedRedisValue Rent(int count, out Span span,
return EmptyArray(type);
}
- var arr = ArrayPool.Shared.Rent(count); // new TypedRedisValue[count];
+ var arr = ArrayPool.Shared.Rent(count);
span = new Span(arr, 0, count);
+ span.Clear(); // Clear the span to ensure safe usage by external callers
return new TypedRedisValue(arr, count, type);
}