Skip to main content

Rate Limiting

Rate limiting protects shared resources by rejecting or delaying work once a caller crosses an allowed request budget. Kahuna is useful here because the counter update can run atomically near the data instead of requiring several client round-trips.

This recipe uses an ephemeral key because rate-limit counters are temporary state. The key expires automatically when the window ends.

Fixed Window Counter

Use one key per subject and time window:

rate-limit/{scope}/{subject}/{window-start}

Examples:

rate-limit/login/203.0.113.10/1718391900
rate-limit/api/user-42/1718391900

The script below creates the counter if it does not exist, increments it if it does, and rejects the request once the limit has been reached. Pass @expires_ms as the remaining time in the current window, optionally with a small grace period. Do not pass the full window length on every request, because that turns the counter into a sliding-expiration counter.

let current = eget @counter_key

if not current then
eset @counter_key 1 ex @expires_ms
return 1
end

let count = to_int(current)

if count >= @limit then
return 0
end

eset @counter_key count + 1 ex @expires_ms
return 1

Run it from the .NET client with parameters:

long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long windowMs = 60_000;
long windowStartMs = nowMs - (nowMs % windowMs);
long windowEndMs = windowStartMs + windowMs;
long expiresMs = Math.Max(1, windowEndMs - nowMs + 1_000);

KahunaKeyValueTransactionResult result = await client.ExecuteKeyValueTransactionScript(
"""
let current = eget @counter_key

if not current then
eset @counter_key 1 ex @expires_ms
return 1
end

let count = to_int(current)

if count >= @limit then
return 0
end

eset @counter_key count + 1 ex @expires_ms
return 1
""",
parameters:
[
new() { Key = "@counter_key", Value = $"rate-limit/login/203.0.113.10/{windowStartMs}" },
new() { Key = "@limit", Value = "10" },
new() { Key = "@expires_ms", Value = expiresMs.ToString() }
]
);

bool allowed = result.FirstValueAsString == "1";

Sliding Expiration Counter

For a simpler throttle, keep one counter per subject and refresh the expiration on every accepted request. Here @ttl_ms is intentionally the full TTL on every accepted request.

let current = eget @counter_key

if not current then
eset @counter_key 1 ex @ttl_ms
return 1
end

let count = to_int(current)

if count >= @limit then
return 0
end

eset @counter_key count + 1 ex @ttl_ms
return 1

This is stricter than a fixed window when a caller sends requests continuously because the key keeps extending while the caller remains active.

Operational Notes

  • Use ephemeral durability for short-lived counters.
  • Keep the script small. Rate limiting sits on a hot path.
  • Use a bucketed key format if you need to inspect counters with eget by bucket.
  • Use persistent durability only when the limit must survive process restarts.
  • For very high-cardinality traffic, choose a TTL that is short enough to avoid unbounded memory growth.