Skip to main content
Version: 1.x

@commandkit/ratelimit

@commandkit/ratelimit is the official CommandKit plugin for advanced rate limiting. It provides multi-window policies, role overrides, queueing, exemptions, and multiple algorithms while keeping command handlers lean.

The ratelimit() factory returns two plugins in order: the compiler plugin for the "use ratelimit" directive and the runtime plugin that enforces limits. Runtime options must be configured before the runtime plugin activates.

Installation

Install the ratelimit plugin to get started:

npm install @commandkit/ratelimit

Setup

Add the ratelimit plugin to your CommandKit configuration and define a runtime config file.

Quick start

Create an auto-loaded runtime config file (for example src/ratelimit.ts) and configure the default limiter:

src/ratelimit.ts
import { configureRatelimit } from '@commandkit/ratelimit';

configureRatelimit({
defaultLimiter: {
maxRequests: 5,
interval: '1m',
scope: 'user',
algorithm: 'fixed-window',
},
});

Register the plugin in your config:

commandkit.config.ts
import { defineConfig } from 'commandkit';
import { ratelimit } from '@commandkit/ratelimit';

export default defineConfig({
plugins: [ratelimit()],
});

The runtime plugin auto-loads ratelimit.ts or ratelimit.js on startup before commands execute.

Runtime configuration lifecycle

Runtime lifecycle diagram

configureRatelimit is required

RateLimitPlugin.activate() throws if configureRatelimit() was not called. This is enforced to avoid silently running without your intended defaults.

Runtime configuration required

Make sure configureRatelimit() runs at startup (for example in ratelimit.ts or ratelimit.js) before the runtime plugin activates. If it does not, the plugin will throw on startup.

How configuration is stored

configureRatelimit() merges your config into an in-memory object and sets the configured flag. getRateLimitConfig() returns the current object, and isRateLimitConfigured() returns whether initialization has happened. If a runtime context is already active, configureRatelimit() updates it immediately.

Runtime storage selection

Storage is resolved in this order:

OrderSourceNotes
1Limiter storage overrideRateLimitLimiterConfig.storage for the command being executed.
2Plugin storage optionRateLimitPluginOptions.storage.
3Process defaultSet via setRateLimitStorage() or setDriver().
4Default memory storageUsed unless initializeDefaultStorage or initializeDefaultDriver is false.

If no storage is resolved and defaults are disabled, the plugin logs once and stores an empty result without limiting.

Runtime helpers

These helpers are process-wide:

HelperPurpose
configureRatelimitSet runtime options and update active runtime state.
getRateLimitConfigRead the merged in-memory runtime config.
isRateLimitConfiguredCheck whether configureRatelimit() was called.
setRateLimitStorageSet the default storage for the process.
getRateLimitStorageGet the process default storage (or null).
setDriver / getDriverAliases for setRateLimitStorage / getRateLimitStorage.
setRateLimitRuntimeSet the active runtime context for APIs and directives.
getRateLimitRuntimeGet the active runtime context (or null).

Basic usage

Use command metadata or the use ratelimit directive to enable rate limiting. This section focuses on command metadata; see the directive section for function-level usage.

Command metadata and enablement

Enable rate limiting by setting metadata.ratelimit:

src/app/commands/ping.ts
export const metadata = {
ratelimit: {
maxRequests: 3,
interval: '10s',
scope: 'user',
algorithm: 'sliding-window',
},
};

metadata.ratelimit can be one of:

ValueMeaning
false or undefinedPlugin does nothing for this command.
trueEnable rate limiting using resolved defaults.
RateLimitCommandConfigEnable rate limiting with command-level overrides.

If env.context is missing in the execution environment, the plugin skips rate limiting.

Named limiter example

commandkit.config.ts
configureRatelimit({
limiters: {
heavy: { maxRequests: 1, interval: '10s', algorithm: 'fixed-window' },
},
});
src/app/commands/report.ts
export const metadata = {
ratelimit: {
limiter: 'heavy',
scope: 'user',
},
};

Configuration reference

RateLimitPluginOptions

FieldTypeDefault or resolutionNotes
defaultLimiterRateLimitLimiterConfigDEFAULT_LIMITER when unsetBase limiter for all commands and directives.
limitersRecord<string, RateLimitLimiterConfig>undefinedNamed limiter presets.
storageRateLimitStorageConfigundefinedResolved before default storage.
keyPrefixstringundefinedPrepended before rl:.
keyResolverRateLimitKeyResolverundefinedUsed for custom scope when the limiter does not override it.
bypassRateLimitBypassOptionsundefinedPermanent allowlists and optional check.
hooksRateLimitHooksundefinedLifecycle callbacks.
onRateLimitedRateLimitResponseHandlerundefinedOverrides default reply.
queueRateLimitQueueOptionsundefinedIf any queue config exists, enabled defaults to true.
roleLimitsRecord<string, RateLimitLimiterConfig>undefinedBase role limits.
roleLimitStrategyRateLimitRoleLimitStrategyhighest when resolvingUsed when multiple roles match.
initializeDefaultStoragebooleantrueDisable to prevent memory fallback.
initializeDefaultDriverbooleantrueAlias for initializeDefaultStorage.

RateLimitLimiterConfig

FieldTypeDefault or resolutionNotes
maxRequestsnumber10 when missing or <= 0Used by fixed and sliding windows.
intervalDurationLike60s when missing or invalidParsed and clamped to >= 1ms.
scopeRateLimitScope or RateLimitScope[]userArrays are deduplicated.
algorithmRateLimitAlgorithmTypefixed-windowUnknown values fall back to fixed-window.
burstnumbermaxRequests when missing or <= 0Capacity for token or leaky buckets.
refillRatenumbermaxRequests / intervalSecondsMust be > 0 for token bucket.
leakRatenumbermaxRequests / intervalSecondsMust be > 0 for leaky bucket.
keyResolverRateLimitKeyResolverundefinedUsed only for custom scope.
keyPrefixstringundefinedOverrides plugin prefix for this limiter.
storageRateLimitStorageConfigundefinedOverrides storage for this limiter.
violationsViolationOptionsundefinedEnables escalation unless escalate is false.
queueRateLimitQueueOptionsundefinedOverrides queue settings at this layer.
windowsRateLimitWindowConfig[]undefinedEnables multi-window behavior.
roleLimitsRecord<string, RateLimitLimiterConfig>undefinedRole overrides at this layer.
roleLimitStrategyRateLimitRoleLimitStrategyhighest when resolvingUsed when role limits match.

RateLimitWindowConfig

FieldTypeDefault or resolutionNotes
idstringw1, w2, ...Auto-generated if empty or missing.
maxRequestsnumberInherits from base limiterApplies only to this window.
intervalDurationLikeInherits from base limiterParsed like the base limiter.
algorithmRateLimitAlgorithmTypeInherits from base limiterUsually keep consistent across windows.
burstnumberInherits from base limiterUsed for token or leaky buckets.
refillRatenumberInherits from base limiterMust be > 0 for token bucket.
leakRatenumberInherits from base limiterMust be > 0 for leaky bucket.
violationsViolationOptionsInherits from base limiterOverrides escalation for this window.

RateLimitQueueOptions

FieldTypeDefault or resolutionNotes
enabledbooleantrue when any queue config existsOtherwise false.
maxSizenumber3 and clamped to >= 1Queue size is pending plus running.
timeoutDurationLike30s and clamped to >= 1msApplies per queued task.
deferInteractionbooleantrue unless explicitly falseOnly used for interactions.
ephemeralbooleantrue unless explicitly falseApplies to deferred replies.
concurrencynumber1 and clamped to >= 1Controls per-key queue concurrency.

ViolationOptions

FieldTypeDefault or resolutionNotes
escalatebooleantrue when violations is setSet false to disable escalation.
maxViolationsnumber5Maximum escalation steps.
escalationMultipliernumber2Multiplies cooldown per violation.
resetAfterDurationLike1hTTL for violation state.

RateLimitCommandConfig

RateLimitCommandConfig extends RateLimitLimiterConfig and adds:

FieldTypeDefault or resolutionNotes
limiterstringundefinedReferences a named limiter in limiters.

Result shapes

RateLimitStoreValue:

FieldTypeMeaning
limitedbooleantrue if any scope or window was limited.
remainingnumberMinimum remaining across all results.
resetAtnumberLatest reset timestamp across all results.
retryAfternumberMax retry delay across limited results.
resultsRateLimitResult[]Individual results per scope and window.

RateLimitResult:

FieldTypeMeaning
keystringStorage key used for the limiter.
scopeRateLimitScopeScope applied for the limiter.
algorithmRateLimitAlgorithmTypeAlgorithm used for the limiter.
windowIdstringPresent for multi-window limits.
limitedbooleanWhether this limiter hit its limit.
remainingnumberRemaining requests or capacity.
resetAtnumberAbsolute reset timestamp in ms.
retryAfternumberDelay until retry is allowed, in ms.
limitnumbermaxRequests for fixed and sliding, burst for token and leaky buckets.

Limiter resolution and role strategy

Limiter configuration is layered in this exact order, with later layers overriding earlier ones:

OrderSourceNotes
1DEFAULT_LIMITERBase defaults.
2defaultLimiterRuntime defaults.
3Named limiterWhen metadata.ratelimit.limiter is set.
4Command overridesmetadata.ratelimit config.
5Role overrideSelected by role strategy.

Limiter resolution diagram

Role limits are merged in this order, with later maps overriding earlier ones for the same role id:

OrderSource
1Plugin roleLimits
2defaultLimiter.roleLimits
3Named limiter roleLimits
4Command roleLimits

Role strategies:

StrategySelection rule
highestPicks the role with the highest request rate (maxRequests / intervalMs).
lowestPicks the role with the lowest request rate.
firstUses insertion order of the merged role limits object.

For multi-window limiters, the score uses the minimum rate across windows.

Scopes and keying

Supported scopes:

ScopeRequired IDsKey format (without keyPrefix)Skip behavior
useruserIdrl:user:{userId}:{commandName}Skips if userId is missing.
guildguildIdrl:guild:{guildId}:{commandName}Skips if guildId is missing.
channelchannelIdrl:channel:{channelId}:{commandName}Skips if channelId is missing.
globalnonerl:global:{commandName}Never skipped.
user-guilduserId, guildIdrl:user:{userId}:guild:{guildId}:{commandName}Skips if either id is missing.
customkeyResolverkeyResolver(ctx, command, source)Skips if resolver is missing or returns falsy.

Keying notes:

  • DEFAULT_KEY_PREFIX is always included in the base format.
  • keyPrefix is concatenated before rl: as-is, so include a trailing separator if you want one.
  • Multi-window limits append :w:{windowId}.

Exemption keys

Temporary exemptions are stored under rl:exempt:{scope}:{id} (plus optional keyPrefix).

Exemption scopeKey formatNotes
userrl:exempt:user:{userId}Resolved from the source user id.
guildrl:exempt:guild:{guildId}Resolved from the guild id.
rolerl:exempt:role:{roleId}Resolved from all member roles.
channelrl:exempt:channel:{channelId}Resolved from the channel id.
categoryrl:exempt:category:{categoryId}Resolved from the parent category id.

Algorithms

Algorithm matrix

AlgorithmRequired configStorage requirementslimit valueNotes
fixed-windowmaxRequests, intervalconsumeFixedWindow or incr or get and setmaxRequestsFallback uses per-process lock and optimistic versioning.
sliding-windowmaxRequests, intervalconsumeSlidingWindowLog or zRemRangeByScore + zCard + zAddmaxRequestsThrows if sorted-set support is missing.
token-bucketburst, refillRateget and setburstThrows if refillRate <= 0.
leaky-bucketburst, leakRateget and setburstThrows if leakRate <= 0.

Fixed window

Execution path:

  1. If consumeFixedWindow exists, it is used.
  2. Else if incr exists, it is used.
  3. Else a fallback uses get and set with a per-process lock.

The limiter is considered limited when count > maxRequests. The fallback path retries up to five times with optimistic versioning and is serialized only within the current process.

Fixed window fallback diagram

Sliding window log

Execution path:

  1. If consumeSlidingWindowLog exists, it is used (atomic).
  2. Else a sorted-set fallback uses zRemRangeByScore, zCard, and zAdd.

If sorted-set methods are missing, the algorithm throws. If zRangeByScore is available, it is used to compute an accurate oldest timestamp for resetAt; otherwise resetAt defaults to now + window. The fallback is serialized per process but is not atomic across processes.

Sliding window fallback diagram

Token bucket

Token bucket uses a stored tokens and lastRefill state. On each consume, tokens refill based on elapsed time and refillRate. If the bucket has fewer than one token, the request is limited and retryAfter is computed from the time required to refill one token.

Leaky bucket

Leaky bucket uses a stored level and lastLeak state. Each request adds one token, and the bucket drains at leakRate. If adding would exceed capacity, the request is limited and retryAfter is computed from the time required to drain the overflow.

Multi-window limits

Use windows to enforce multiple windows simultaneously:

configureRatelimit({
defaultLimiter: {
scope: 'user',
algorithm: 'sliding-window',
windows: [
{ id: 'short', maxRequests: 10, interval: '1m' },
{ id: 'long', maxRequests: 1000, interval: '1d' },
],
},
});

If a window id is omitted, the plugin generates w1, w2, and so on. Window ids are part of the storage key and appear in results.

Storage

Storage interface

Required methods:

MethodUsed byNotes
getAll algorithmsReturns stored value or null.
setAll algorithmsOptional ttlMs controls expiry.
deleteResets and algorithm resetsRemoves stored state.

Optional methods and features:

MethodFeatureNotes
consumeFixedWindowFixed-window atomic consumeUsed before incr and fallback.
incrFixed-window efficiencyReturns count and TTL.
consumeSlidingWindowLogSliding-window atomic consumePreferred over sorted-set fallback.
zAdd / zRemRangeByScore / zCardSliding-window fallbackRequired when consumeSlidingWindowLog is absent.
zRangeByScoreSliding-window reset accuracyImproves resetAt computation.
ttlExemption listingUsed for expiresInMs.
expireSliding-window fallbackKeeps sorted-set keys from growing indefinitely.
deleteByPrefix / deleteByPatternResetsRequired by resetAllRateLimits and HMR.
keysByPrefixExemption listingRequired for listing without a specific id.

Capability matrix

FeatureRequiresMemoryRedisFallback
Fixed-window atomic consumeconsumeFixedWindowYesYesConditional (both storages)
Fixed-window incrincrYesYesConditional (both storages)
Sliding-window atomic consumeconsumeSlidingWindowLogYesYesConditional (both storages)
Sliding-window fallbackzAdd + zRemRangeByScore + zCardYesYesConditional (both storages)
TTL visibilityttlYesYesConditional (both storages)
Prefix or pattern deletesdeleteByPrefix or deleteByPatternYesYesConditional (both storages)
Exemption listingkeysByPrefixYesYesConditional (both storages)

Capability overview diagram

Memory storage

import {
MemoryRateLimitStorage,
setRateLimitStorage,
} from '@commandkit/ratelimit';

setRateLimitStorage(new MemoryRateLimitStorage());

Notes:

  • In-memory only; not safe for multi-process deployments.
  • Implements TTL and sorted-set helpers.
  • deleteByPattern supports a simple * wildcard, not full glob syntax.
Single-process only

Memory storage is per process. For multiple bot shards or instances, use a shared storage like Redis.

Redis storage

import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis';
import { setRateLimitStorage } from '@commandkit/ratelimit';

setRateLimitStorage(
new RedisRateLimitStorage({ host: 'localhost', port: 6379 }),
);

Notes:

  • Stores values as JSON.
  • Uses Lua scripts for atomic fixed and sliding windows.
  • Uses SCAN for prefix and pattern deletes and listing.

Fallback storage

import { FallbackRateLimitStorage } from '@commandkit/ratelimit/fallback';
import { MemoryRateLimitStorage } from '@commandkit/ratelimit/memory';
import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis';
import { setRateLimitStorage } from '@commandkit/ratelimit';

const primary = new RedisRateLimitStorage({ host: 'localhost', port: 6379 });
const secondary = new MemoryRateLimitStorage();

setRateLimitStorage(new FallbackRateLimitStorage(primary, secondary));

Notes:

  • Every optional method must exist on both storages or the fallback wrapper throws.
  • Primary errors are logged at most once per cooldownMs window (default 30s).

Queue mode

Queue mode retries commands instead of rejecting immediately.

Use queueing to smooth bursts

Queueing is useful for smoothing short bursts, but it changes response timing. Disable it with queue: { enabled: false } if you want strict, immediate rate-limit responses.

Queue defaults and clamps

FieldDefaultClampNotes
enabledtrue if any queue config existsn/aOtherwise false.
maxSize3>= 1Queue size is pending plus running.
timeout30s>= 1msPer queued task.
deferInteractiontruen/aOnly applies to interactions.
ephemeraltruen/aApplies to deferred replies.
concurrency1>= 1Per queue key.

Queue flow

  1. Rate limit is evaluated and an aggregate result is computed.
  2. If limited and queueing is enabled, the plugin tries to enqueue.
  3. If the queue is full, it falls back to immediate rate-limit handling.
  4. When queued, the interaction is deferred if it is repliable and not already replied or deferred.
  5. The queued task waits retryAfter, then re-checks the limiter; if still limited it waits at least 250ms and retries until timeout.

Queue flow diagram

Violations and escalation

Violation escalation is stored under violation:{key} and uses these defaults:

OptionDefaultMeaning
maxViolations5Maximum escalation steps.
escalationMultiplier2Multiplier per repeated violation.
resetAfter1hTTL for violation state.
escalatetrue when violations is setSet false to disable escalation.

Formula:

cooldown = baseRetryAfter * multiplier^(count - 1)

If escalation produces a later resetAt than the algorithm returned, the result is updated so resetAt and retryAfter stay accurate.

Bypass and exemptions

Bypass order is always:

  1. bypass.userIds, bypass.guildIds, and bypass.roleIds.
  2. Temporary exemptions stored in storage.
  3. bypass.check(source).

Bypass example:

configureRatelimit({
bypass: {
userIds: ['USER_ID'],
guildIds: ['GUILD_ID'],
roleIds: ['ROLE_ID'],
check: (source) => source.channelId === 'ALLOWLIST_CHANNEL',
},
});

Temporary exemptions:

import { grantRateLimitExemption } from '@commandkit/ratelimit';

await grantRateLimitExemption({
scope: 'user',
id: 'USER_ID',
duration: '1h',
});

Listing behavior:

  • listRateLimitExemptions({ scope, id }) reads a single key directly.
  • listRateLimitExemptions({ scope }) scans by prefix and requires keysByPrefix.
  • expiresInMs is null when ttl is not supported.

Responses, hooks, and events

Default response behavior

SourceConditionsAction
MessageChannel is sendablereply() with cooldown embed.
InteractionRepliable and not replied/deferredreply() with ephemeral cooldown embed.
InteractionRepliable and already replied/deferredfollowUp() with ephemeral cooldown embed.
InteractionNot repliableNo response.

The default embed title is :hourglass_flowing_sand: You are on cooldown and the description uses a relative timestamp based on resetAt.

Hooks

HookCalled whenNotes
onAllowedCommand is allowedReceives the first result.
onRateLimitedCommand is limitedReceives the first limited result.
onViolationA violation is recordedReceives key and violation count.
onResetresetRateLimit succeedsNot called by resetAllRateLimits.
onStorageErrorStorage operation failsfallbackUsed is false in runtime plugin paths.

Analytics events

The runtime plugin calls ctx.commandkit.analytics.track(...) with:

Event nameWhen
ratelimit_allowedAfter an allowed consume.
ratelimit_hitAfter a limited consume.
ratelimit_violationWhen escalation records a violation.

Event bus

A ratelimited event is emitted on the ratelimits channel:

commandkit.events
.to('ratelimits')
.on(
'ratelimited',
({ key, result, source, aggregate, commandName, queued }) => {
console.log(key, commandName, queued, aggregate.retryAfter);
},
);

Payload fields include key, result, source, aggregate, commandName, and queued.

Resets and HMR

resetRateLimit

resetRateLimit clears the base key, its violation: key, and any window variants. It accepts either a raw key or a scope-derived key.

ModeRequired paramsNotes
DirectkeyResets key, violation:key, and window variants.
Scopedscope + commandName + required idsThrows if identifiers are missing.

resetAllRateLimits

resetAllRateLimits supports several modes and requires storage delete helpers:

ModeRequired paramsStorage requirement
PatternpatterndeleteByPattern
PrefixprefixdeleteByPrefix
Command namecommandNamedeleteByPattern
Scopescope + required idsdeleteByPrefix

HMR reset behavior

When a command file is hot-reloaded, the plugin deletes keys that match:

  • *:{commandName}
  • violation:*:{commandName}
  • *:{commandName}:w:*
  • violation:*:{commandName}:w:*

HMR reset requires deleteByPattern. If the storage does not support pattern deletes, nothing is cleared.

Directive: use ratelimit

The compiler plugin (UseRateLimitDirectivePlugin) uses CommonDirectiveTransformer with directive = "use ratelimit" and importName = "$ckitirl". It transforms async functions only.

The runtime wrapper:

  • Uses the runtime default limiter (merged with DEFAULT_LIMITER).
  • Generates a per-function key rl:fn:{uuid} and applies keyPrefix if present.
  • Aggregates results across windows and throws RateLimitError when limited.
  • Caches the wrapper per function and exposes it as globalThis.$ckitirl.

Example:

import { RateLimitError } from '@commandkit/ratelimit';

const heavy = async () => {
'use ratelimit';
return 'ok';
};

try {
await heavy();
} catch (error) {
if (error instanceof RateLimitError) {
console.log(error.result.retryAfter);
}
}

Defaults and edge cases

Defaults

SettingDefault
maxRequests10
interval60s
algorithmfixed-window
scopeuser
DEFAULT_KEY_PREFIXrl:
RATELIMIT_STORE_KEYratelimit
roleLimitStrategyhighest
queue.maxSize3
queue.timeout30s
queue.deferInteractiontrue
queue.ephemeraltrue
queue.concurrency1
initializeDefaultStoragetrue

Edge cases

  1. If no storage is configured and default storage is disabled, the plugin logs once and stores an empty result without limiting.
  2. If no scope key can be resolved, the plugin stores an empty result and skips limiting.
  3. If storage errors occur during consume, onStorageError is invoked and the plugin skips limiting for that execution.
  4. For token and leaky buckets, limit equals burst. For fixed and sliding windows, limit equals maxRequests.

Duration parsing

DurationLike accepts numbers (milliseconds) or strings parsed by ms, plus custom units for weeks and months.

UnitMeaning
ms, s, m, h, dStandard ms units.
w, week, weeks7 days.
mo, month, months30 days.

Exports

ExportDescription
ratelimitPlugin factory returning compiler + runtime plugins.
RateLimitPluginRuntime plugin class.
UseRateLimitDirectivePluginCompiler plugin for use ratelimit.
RateLimitEngineAlgorithm coordinator with escalation handling.
Algorithm classesFixed, sliding, token bucket, and leaky bucket implementations.
Storage classesMemory, Redis, and fallback storage.
Runtime helpersconfigureRatelimit, setRateLimitStorage, getRateLimitRuntime, and more.
API helpersgetRateLimitInfo, resets, and exemption helpers.
RateLimitErrorError thrown by the directive wrapper.

Subpath exports:

  • @commandkit/ratelimit/redis
  • @commandkit/ratelimit/memory
  • @commandkit/ratelimit/fallback