RateLimiter v3.1
Production-grade distributed rate limiting for Roblox. Four algorithms, G-Counter CRDT token bucket, ring-buffer sliding window, vector clock causality, and idempotent delta replication across servers.
Four Strategies
Sliding window, token bucket, fixed window, and leaky bucket - each configurable per endpoint
CRDT Token Bucket
G-Counter CRDT with idempotent max-merge: mathematically sound cross-server token accounting
Ring Buffer
O(1) amortized sliding window with head/tail pointers - no allocations per check
Vector Clocks
Causal ordering with per-bucket staleness checks - stale updates are discarded, not applied
Penalty Replication
Penalties sync across servers - users cannot bypass bans by switching server
Idempotent Delivery
UUID dedup store prevents double-application of redelivered MessagingService messages
Why RateLimiter?
Most Roblox rate limiters are single-server only, use process-uptime timestamps, or bolt on "distributed" mode by injecting synthetic timestamps into shared state. This one is built differently:
- Wall clock time:
DateTime.now().UnixTimestampMillis- comparable across servers - Real CRDT: Token bucket is a G-Counter - merge is idempotent, commutative, and associative by construction
- Actual causality:
happens_beforeis used, not just implemented for decoration - Honest about limits: AP system, not CP - see Distributed Limitations
What's New in v3.1
v3.1 is a targeted fix pass on v3.0. No architectural changes - only correctness and precision improvements.
Vector Clock Snapshot on Publish
Outbound messages now carry a shallow copy of the vector clock at send time. Previously, the live reference was embedded - any clock increment between batch construction and PublishAsync completing would corrupt the snapshot non-deterministically.
Per-Bucket Vector Clock Staleness Check
Before applying each incoming delta, the system now checks happens_before(update.vector_clock, bucket.vector_clock) against the bucket's clock, not just the cluster clock. A causally old update that isn't dominated at the cluster level but is dominated per-bucket is now correctly skipped.
SyncIntervalSeconds at Limiter Level
Previously configurable only per-limit. Now also configurable on the limiter itself as a global default. The sync loop uses max(sync_interval, backoff) so backoff still overrides it correctly on failure.
MaxServerRequestsPerUser (renamed)
Renamed from MaxGlobalRequestsPerUser. The old name implied cross-cluster aggregation, which was never true. This ceiling is per-server only.
Breaking Changes
One rename: MaxGlobalRequestsPerUser → MaxServerRequestsPerUser. Everything else is backward compatible.
Getting Started
Installation
Place RateLimiter.lua in ReplicatedStorage. Require it from a Script in ServerScriptService. RateLimiter is server-only - it will assert if required from the client.
Basic Setup
local RateLimiter = require(game.ReplicatedStorage.RateLimiter)
local limiter = RateLimiter.new({
EnableDistributed = true, -- sync across servers via MessagingService
MaxServerRequestsPerUser = 1000, -- per-server ceiling across all endpoints
SyncIntervalSeconds = 5, -- base distributed sync interval
})
-- define a limit
limiter:CreateLimit("chat", {
MaxRequests = 5,
WindowSeconds = 10,
Strategy = "sliding_window",
BurstAllowance = 1.0, -- no burst for chat
ViolationThreshold = 3,
PenaltyDurationSeconds = 30,
OnViolation = function(userId, key, info)
warn("Violation:", userId, "count:", info.ViolationCount)
end,
})
-- check on each player action
game.Players.PlayerAdded:Connect(function(player)
-- hook to your RemoteEvent or action
local result = limiter:Check(player.UserId, "chat")
if result.Allowed then
-- proceed
else
print("Rate limited. Retry after:", result.RetryAfterMs, "ms")
end
end)
Single-Server Setup
If your game runs on one server, disable distributed mode entirely - there is no benefit and it adds MessagingService overhead.
local limiter = RateLimiter.new({
EnableDistributed = false,
})
Strategies
Each limit can use a different algorithm. Choose based on the traffic shape you want to enforce.
Sliding Window (Default)
Tracks the exact timestamp of every request in a ring buffer. On each check, expired entries are evicted from the front. Most accurate, because there are no window-boundary artifacts.
limiter:CreateLimit("chat", {
MaxRequests = 5,
WindowSeconds = 10,
Strategy = "sliding_window",
BurstAllowance = 1.0, -- 1.0 = no burst, 1.2 = 20% burst headroom
})
Characteristics
- O(1) amortized push/pop via ring buffer
- No GC pressure - no table allocations on each check
- Accurate to the millisecond
- In distributed mode: per-server enforcement only (no cross-server merging)
Best For
- Chat, RemoteEvent spam prevention
- Any endpoint where accuracy matters more than cross-server strictness
Token Bucket
Tokens refill at a constant rate up to a configurable capacity. Allows bursts up to BurstAllowance × MaxRequests. In distributed mode, this strategy uses a proper G-Counter CRDT - cross-server consumption is correctly accounted for.
limiter:CreateLimit("trade", {
MaxRequests = 10,
WindowSeconds = 60,
Strategy = "token_bucket",
BurstAllowance = 1.0, -- no burst for economy
CostPerRequest = 1, -- default cost; override per-check for variable cost
})
Variable Cost
Pass a cost argument to Check() to consume more than one token per request:
-- cheap endpoint: 1 token
limiter:Check(userId, "api", 1)
-- expensive endpoint: 10 tokens
limiter:Check(userId, "api", 10)
How the CRDT Works
consumed[server_id]- monotonically increasing per server- Merge:
max(local, remote)per server entry - idempotent, commutative, associative tokens_earned = min(capacity, elapsed_since_epoch × refill_rate)- deterministic from wall clockavailable = tokens_earned − sum(all consumed)- Bucket starts full - first request is never blocked on cold start
Best For
- Economy actions, trading, DataStore writes - anywhere cross-server convergence matters
- Variable-cost endpoints where heavier operations consume more quota
Fixed Window
Resets a counter at deterministic boundaries: floor(now / window_ms) × window_ms. Fast and cheap. Has the classical boundary-burst flaw - a user can consume 2× the limit by hitting the end of one window and the start of the next.
limiter:CreateLimit("analytics", {
MaxRequests = 100,
WindowSeconds = 60,
Strategy = "fixed_window",
})
Best For
- Non-critical endpoints where the boundary-burst flaw is acceptable
- Analytics, logging, low-stakes rate gating
Leaky Bucket
Requests fill a queue that drains at a constant rate. Provides smooth, shaped output. If the queue is full, the request is rejected.
limiter:CreateLimit("remoteEvent", {
MaxRequests = 30,
WindowSeconds = 10,
Strategy = "leaky_bucket",
})
Best For
- Traffic shaping where you want a smooth constant output rate
- Preventing burst-induced server spikes
Strategy Comparison
| Strategy | Accuracy | Distributed | Burst Support | Best For |
|---|---|---|---|---|
| Sliding Window | Highest | Per-server only | Via BurstAllowance | Chat, spam prevention |
| Token Bucket | High | CRDT convergent | Yes (configurable) | Economy, APIs, trading |
| Fixed Window | Lower (boundary flaw) | Delta replicated | No | Analytics, low-stakes |
| Leaky Bucket | High | Delta replicated | No (queue-based) | Traffic shaping |
Distributed Mode
RateLimiter uses MessagingService to propagate state changes across servers. Each server maintains its own local buckets and exchanges updates periodically.
G-Counter CRDT (Token Bucket)
Token bucket state is a proper state-based G-Counter CRDT. This means the distributed merge is mathematically sound:
-- Each server tracks only its own consumption, monotonically
state.consumed[SERVER_ID] += cost
-- Remote merge: take max per server entry (idempotent)
state.consumed[remote_id] = math.max(local_consumed, remote_consumed)
-- Available tokens is deterministic on all servers
local tokens_earned = math.min(capacity, elapsed_since_epoch * refill_rate)
local available = tokens_earned - sum(state.consumed)
Because merge is idempotent, redelivered messages do not cause double-counting even without the dedup layer (though dedup is still present as a defense-in-depth measure).
Vector Clocks & Causality
Every bucket carries a vector clock. Incoming updates are subject to two staleness checks before being applied:
- Cluster-level check: If the update's vector clock is strictly dominated by the cluster's current clock, the entire batch is discarded.
- Per-bucket check: Before applying each delta, the update's clock is compared against that specific bucket's clock. If it's causally dominated, the delta is skipped.
-- cluster-level guard
if VectorClockImpl.happens_before(update.vector_clock, self._vector_clock) then
return -- entire batch is stale
end
-- per-bucket guard (before merging)
if VectorClockImpl.happens_before(update.vector_clock, bucket.vector_clock) then
continue -- this specific delta is stale
end
Idempotent Delivery
Every outbound message carries a UUID (update_id). A dedup store keyed by ID is maintained on each server and pruned every 5 minutes. Redelivered messages are silently discarded.
Penalty Replication
When a user enters a penalty box, penalty_until_ms is included in the next distributed sync. Receiving servers apply max(local, remote) - the stricter expiry always wins. A user cannot escape a penalty by switching servers.
Limitations
- Sliding window: No cross-server merging. A user hitting N servers in parallel can consume up to N × limit per window before convergence. For strict cross-server enforcement, use token bucket or a central authority.
- MaxServerRequestsPerUser: Per-server ceiling only, not synchronized across the cluster. A user hitting multiple servers simultaneously can exceed it across servers.
- Sync latency: Default 5 seconds. During this window, each server enforces independently up to its local limit. Lower
SyncIntervalSecondsto reduce the window at the cost of more MessagingService publishes. - Clock skew:
DateTime.now().UnixTimestampMillisis not tightly synchronized across Roblox servers. A server running a few seconds fast will earn tokens slightly faster. This cannot be corrected without a trusted monotonic time source that Roblox does not expose.
What Distributed Mode Protects Against
- Honest overload and accidental spam
- Mild abuse across multiple servers (token bucket CRDT convergence)
- Server-hop penalty bypass (penalty replication)
- MessagingService redelivery double-counts (UUID dedup)
What It Does Not Protect Against
- Coordinated flooding within the sync window across N servers simultaneously
- Time skew exploitation
- Strict global sliding window enforcement
API Reference
RateLimiter.new()
RateLimiter.new(config?: RateLimiterConfig) → RateLimiter
Creates a new rate limiter instance. Must be called from a server Script.
| Field | Type | Default | Description |
|---|---|---|---|
| EnableDistributed | boolean | true | Enable cross-server sync via MessagingService |
| DefaultStrategy | RateLimitStrategy | "sliding_window" | Algorithm used when Strategy is not specified per-limit |
| SyncIntervalSeconds | number | 5 | Base interval between distributed syncs. Backoff overrides this on failure. |
| CleanupIntervalSeconds | number | 30 | Seconds between cleanup passes for expired buckets |
| CompactionThreshold | number | 10000 | Bucket count that triggers LRU compaction |
| MaxServerRequestsPerUser | number | 1000 | Per-server ceiling across all endpoints. Not globally aggregated. |
| EnableMetrics | boolean | false | Enable metrics collection |
| MetricsIntervalSeconds | number | 60 | Seconds between metric snapshots |
| OnMetrics | function | nil | Callback receiving Metrics on each interval |
CreateLimit()
limiter:CreateLimit(key: string, config: LimitConfig)
Defines a named rate limit. Must be called before Check() with the same key.
| Field | Type | Default | Description |
|---|---|---|---|
| MaxRequests | number | required | Maximum requests per window |
| WindowSeconds | number | required | Duration of the rate limit window in seconds |
| Strategy | RateLimitStrategy | DefaultStrategy | "sliding_window" | "token_bucket" | "fixed_window" | "leaky_bucket" |
| BurstAllowance | number | 1.2 | Burst capacity multiplier. 1.0 = no burst. 1.5 = 50% burst headroom. |
| CostPerRequest | number | 1 | Default token cost. Can be overridden per-check. |
| ViolationThreshold | number | 5 | Number of violations before penalty is applied |
| ViolationDecaySeconds | number | 300 | Time per violation decay tick (one violation removed per tick) |
| PenaltyDurationSeconds | number | 60 | Base penalty duration |
| PenaltyMultiplier | number | 2.0 | Penalty duration multiplier on each threshold breach |
| SyncIntervalSeconds | number | 5 | Per-limit distributed sync interval (overrides limiter default) |
| OnViolation | function | nil | Called when penalty is applied: (userId, key, ViolationInfo) |
| OnWarning | function | nil | Called when remaining < 20% of limit: (userId, key, remaining) |
Check()
limiter:Check(userId: number, key: string, cost?: number) → LimitResult
Checks whether a user is allowed to perform an action under the named limit. Errors if the key has not been registered with CreateLimit().
Returns a LimitResult:
{
Allowed: boolean,
Remaining: number, -- requests remaining in current window
ResetAtMs: number, -- unix timestamp (ms) when window resets
RetryAfterMs: number?, -- ms until retry is allowed (nil if Allowed)
Metadata: {
Strategy: string,
CurrentCount: number,
Limit: number,
WindowStartMs: number,
WindowEndMs: number,
PenaltyActive: boolean,
},
}
Reset()
limiter:Reset(userId: number, key: string)
Clears the rate limit bucket for a user/key pair. The next Check() will behave as if the user has never made a request.
GetMetrics()
limiter:GetMetrics() → Metrics
Returns current metrics. Requires EnableMetrics = true in config.
{
TotalRequests: number,
AllowedRequests: number,
BlockedRequests: number,
UniqueUsers: number,
UniqueKeys: number,
ApproximateMemoryMB: number, -- heuristic estimate, not precise
ViolationsByKey: {[string]: number},
AverageLatencyMs: number,
}
GetBucketInfo()
limiter:GetBucketInfo(userId: number, key: string) → BucketState?
Returns internal bucket state for a user/key pair. Returns nil if no bucket exists. Useful for debugging violations, penalties, and CRDT state.
Shutdown()
limiter:Shutdown()
Flushes any pending distributed updates before the server shuts down. Call this from game:BindToClose().
game:BindToClose(function()
limiter:Shutdown()
end)
Configuration
Recommended Setups by Game Size
Single Server / Small Game
local limiter = RateLimiter.new({
EnableDistributed = false, -- not needed for single server
EnableMetrics = true,
MaxServerRequestsPerUser = 500,
})
Multi-Server Game (10–50 servers)
local limiter = RateLimiter.new({
EnableDistributed = true,
SyncIntervalSeconds = 5, -- default; lower for stricter cross-server enforcement
MaxServerRequestsPerUser = 1000,
EnableMetrics = true,
CleanupIntervalSeconds = 60,
})
High-Traffic Game (50+ servers)
local limiter = RateLimiter.new({
EnableDistributed = true,
SyncIntervalSeconds = 10, -- reduce messaging load
MaxServerRequestsPerUser = 1000,
CompactionThreshold = 5000, -- more aggressive LRU compaction
CleanupIntervalSeconds = 30,
EnableMetrics = true,
OnMetrics = function(metrics)
if metrics.ApproximateMemoryMB > 50 then
warn("RateLimiter memory pressure:", metrics.ApproximateMemoryMB, "MB")
end
end,
})
Limit Configuration Examples
Chat (Strict)
limiter:CreateLimit("chat", {
MaxRequests = 5,
WindowSeconds = 10,
Strategy = "sliding_window",
BurstAllowance = 1.0,
ViolationThreshold = 3,
ViolationDecaySeconds = 120,
PenaltyDurationSeconds = 30,
})
Economy / Trading (CRDT-convergent)
limiter:CreateLimit("trade", {
MaxRequests = 10,
WindowSeconds = 60,
Strategy = "token_bucket", -- G-Counter CRDT; converges across servers
BurstAllowance = 1.0, -- no burst for economy
ViolationThreshold = 3,
PenaltyDurationSeconds = 600, -- 10 min
})
RemoteEvent Flood Prevention
limiter:CreateLimit("remoteEvent", {
MaxRequests = 60,
WindowSeconds = 1,
Strategy = "sliding_window",
BurstAllowance = 1.5,
ViolationThreshold = 10,
PenaltyDurationSeconds = 5,
})
Variable-Cost API Endpoint
limiter:CreateLimit("api", {
MaxRequests = 100,
WindowSeconds = 60,
Strategy = "token_bucket",
BurstAllowance = 1.5,
CostPerRequest = 1, -- default; override at Check() time
})
Best Practices
1. Use Token Bucket for Economy
Token bucket is the only strategy with mathematically convergent cross-server enforcement. Use it for any endpoint where a user exceeding the limit across servers is harmful.
-- trading, purchases, currency grants
limiter:CreateLimit("economy", {
Strategy = "token_bucket",
BurstAllowance = 1.0, -- no burst
MaxRequests = 5,
WindowSeconds = 60,
})
2. Do Not Use RateLimiter Alone for Financial Invariants
This is an AP system. It is eventually consistent. For irreversible actions like purchases or currency grants, always pair it with a DataStore transaction or a server-authoritative check:
-- rate limit first (fast reject)
local result = limiter:Check(userId, "purchase")
if not result.Allowed then return end
-- then do the transactional check (authoritative)
DataStore:UpdateAsync(userId, function(data)
if data.LastPurchase and os.time() - data.LastPurchase < 60 then
return nil -- abort
end
data.LastPurchase = os.time()
return data
end)
3. Call Shutdown() on Server Close
game:BindToClose(function()
limiter:Shutdown()
end)
4. Monitor Violations and Metrics
local limiter = RateLimiter.new({
EnableMetrics = true,
OnMetrics = function(metrics)
-- surface to your observability system
print("Blocked rate:", metrics.BlockedRequests / metrics.TotalRequests)
print("Violations:", metrics.ViolationsByKey)
end,
})
limiter:CreateLimit("chat", {
OnViolation = function(userId, key, info)
warn("Penalty applied:", userId, "violations:", info.ViolationCount)
-- optionally: flag the user, log to DataStore, etc.
end,
OnWarning = function(userId, key, remaining)
-- user is above 80% of limit, warn them if appropriate
end,
})
5. Tune Penalty Multiplier Carefully
With PenaltyMultiplier = 2.0 (default), the penalty duration doubles on each threshold breach. If a user repeatedly hits the limit immediately after each penalty expires, the penalty grows quickly. Make sure this matches your intent:
-- chat spam: escalating penalty is desirable
limiter:CreateLimit("chat", {
PenaltyDurationSeconds = 30,
PenaltyMultiplier = 2.0, -- 30s → 60s → 120s...
})
-- economy: flat penalty, no escalation
limiter:CreateLimit("trade", {
PenaltyDurationSeconds = 600,
PenaltyMultiplier = 1.0, -- always exactly 10 minutes
})
Examples
Chat System with Mute
local Players = game:GetService("Players")
local limiter = RateLimiter.new({ EnableDistributed = true })
limiter:CreateLimit("chat", {
MaxRequests = 5,
WindowSeconds = 10,
Strategy = "sliding_window",
BurstAllowance = 1.0,
ViolationThreshold = 3,
PenaltyDurationSeconds = 30,
OnViolation = function(userId, key, info)
local player = Players:GetPlayerByUserId(userId)
if player then
player:SetAttribute("Muted", true)
task.delay(30, function()
if player and player.Parent then
player:SetAttribute("Muted", false)
end
end)
end
end,
})
-- hook to your chat RemoteEvent
ChatRemote.OnServerEvent:Connect(function(player, message)
if player:GetAttribute("Muted") then return end
local result = limiter:Check(player.UserId, "chat")
if not result.Allowed then
-- optionally inform the player
return
end
-- process chat
end)
Trading System
local limiter = RateLimiter.new({
EnableDistributed = true,
SyncIntervalSeconds = 3, -- tighter sync for economy
})
limiter:CreateLimit("trade", {
MaxRequests = 5,
WindowSeconds = 60,
Strategy = "token_bucket", -- G-Counter CRDT cross-server convergence
BurstAllowance = 1.0,
ViolationThreshold = 2,
PenaltyDurationSeconds = 300,
OnViolation = function(userId, key, info)
warn("Trade rate limit violation:", userId)
end,
})
TradeRemote.OnServerEvent:Connect(function(player, tradeData)
local result = limiter:Check(player.UserId, "trade")
if not result.Allowed then
-- inform player
return
end
-- process trade
end)
Remote Event Flood Guard
local limiter = RateLimiter.new({ EnableDistributed = false })
-- 60 requests per second max, burst to 90
limiter:CreateLimit("remote", {
MaxRequests = 60,
WindowSeconds = 1,
Strategy = "sliding_window",
BurstAllowance = 1.5,
ViolationThreshold = 5,
PenaltyDurationSeconds = 10,
})
MyRemote.OnServerEvent:Connect(function(player, ...)
local result = limiter:Check(player.UserId, "remote")
if not result.Allowed then return end
-- handle event
end)
Variable-Cost API
limiter:CreateLimit("api", {
MaxRequests = 100,
WindowSeconds = 60,
Strategy = "token_bucket",
BurstAllowance = 1.5,
})
-- cheap read endpoint
local r1 = limiter:Check(userId, "api", 1)
-- expensive write endpoint
local r2 = limiter:Check(userId, "api", 10)
-- very expensive batch endpoint
local r3 = limiter:Check(userId, "api", 25)
Metrics Monitoring
local limiter = RateLimiter.new({
EnableDistributed = true,
EnableMetrics = true,
MetricsIntervalSeconds = 30,
OnMetrics = function(m)
local block_rate = m.TotalRequests > 0
and (m.BlockedRequests / m.TotalRequests * 100)
or 0
print(string.format(
"[RateLimit] total=%d allowed=%d blocked=%d (%.1f%%) users=%d mem=%.2fMB latency=%.3fms",
m.TotalRequests, m.AllowedRequests, m.BlockedRequests,
block_rate, m.UniqueUsers, m.ApproximateMemoryMB, m.AverageLatencyMs
))
end,
})
Performance
Algorithmic Complexity
| Strategy | Check Complexity | Memory per Bucket | Notes |
|---|---|---|---|
| Sliding Window | O(1) amortized | Ring buffer (8 bytes/entry) | Eviction is O(k) worst case but amortized O(1) |
| Token Bucket | O(n) where n = server count | One number per known server | n is typically 1–3 in distributed mode |
| Fixed Window | O(1) | Two numbers | Cheapest per check |
| Leaky Bucket | O(1) | Two numbers | Same as fixed window |
MessagingService Budget
| Servers | Sync Interval | Publishes/Min | vs Limit (150/min) |
|---|---|---|---|
| 10 | 5s | ~12 | 8% |
| 50 | 5s | ~60 | 40% |
| 100 | 5s | ~120 | 80% (backoff will kick in) |
| 100 | 10s | ~60 | 40% |
Exponential backoff (1s → 30s max) activates automatically if MessagingService rate is exceeded. Failed batches are re-queued and retried.
Memory Usage
Approximate memory per bucket: ~250 bytes (ring buffer metadata + bucket struct). Actual memory depends on how many timestamps the sliding window ring is holding at a given moment (~8 bytes each). Memory estimation is exposed in GetMetrics().ApproximateMemoryMB - this is a heuristic, not a precise measurement.
LRU compaction removes the oldest 10% of buckets when the count exceeds CompactionThreshold. Inactive buckets are also pruned on each cleanup pass.
v1.0 → v3.1 Changes
| Issue | v1.0 | v3.1 |
|---|---|---|
| Time source | os.clock() (process uptime) | DateTime.now() (wall clock ms) |
| Sliding window | O(k) table allocation per check | O(1) amortized ring buffer |
| Token burst | BurstAllowance ignored | Correctly applied to capacity |
| Distributed | Synthetic timestamp injection | G-Counter CRDT with vector clocks |
| Message idempotency | None | UUID dedup with TTL pruning |
| Penalty replication | Local only | Synced via max-merge |
| Vector clock usage | Decorative | Cluster + per-bucket staleness checks |
Troubleshooting
Users Blocked Too Aggressively
- Increase
MaxRequestsorBurstAllowance - Increase
ViolationThresholdso penalties apply less often - Increase
ViolationDecaySecondsfor faster forgiveness - Lower
PenaltyMultiplierto 1.0 to prevent escalation
Users Bypassing Limits Across Servers
- Switch to
Strategy = "token_bucket"- the only strategy with cross-server CRDT convergence - Lower
SyncIntervalSecondsto reduce the enforcement window (at the cost of more messaging publishes) - For strict enforcement, pair with a DataStore transaction
MessagingService Errors
If you see RateLimiter: messaging rate limit reached, backing off:
- Increase
SyncIntervalSeconds(e.g., 10–15 for large games) - The system self-recovers via exponential backoff (1s → 30s)
- Failed updates are re-queued and sent on the next successful publish
High Memory Usage
- Lower
CleanupIntervalSecondsso inactive buckets are removed sooner - Lower
CompactionThresholdto trigger LRU compaction earlier - Check
GetMetrics().ApproximateMemoryMB- if it is consistently high, you may have a large active user base; this is expected
Debugging a Specific User
local bucket = limiter:GetBucketInfo(userId, "chat")
if bucket then
print("Strategy:", bucket.strategy)
print("Violations:", bucket.violations)
print("Penalty until:", bucket.penalty_until_ms)
-- for token bucket: inspect CRDT state
if bucket.strategy == "token_bucket" then
local consumed_total = 0
for server_id, v in pairs(bucket.state.consumed) do
print(" consumed by", server_id, ":", v)
consumed_total += v
end
print("Total consumed:", consumed_total)
end
end
Getting Help
- Enable
EnableMetrics = trueand useOnMetricsto surface data - Use
GetBucketInfo()to inspect internal state per user - Contact @chi0sk on Discord