Skip to content

The rate limiter

Count uses against any key you build with the rate limiter. Covers charging and reading a window, building keys, clearing a key, and writing a durable store.

Some limits don't belong to whoever ran the command. A member can receive three awards a day, however many people hand them out. A Cooldown gate can't express that, since it counts per caller, per server, or per channel.

For those limits you charge a window yourself. core.rateLimiter is the same store Cooldown charges. It counts uses against any key you build. A handler reads it through this.core.rateLimiter. A gate reads it through ctx.core.rateLimiter.

Charging a key

charge reports whether the key is limited. When a slot is free, it also records a use.

Say /award gives a member points. The limit of three a day belongs to the member being awarded, so Award builds the key from their id.

src/handlers/Award.tshover for typestap for types, arrow keys walk the tokens
import {
    
function buildKey(
    prefix: string,
    ...parts: readonly (string | null | undefined)[]
): string
buildKey
,
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
,
function SlashRoute<const Route extends keyof SlashRegistry>(
    ...routes: Route[]
): <TCtor extends AnyHandlerCtor>(
    constructor: AssertSlashRoute<Route, TCtor>
) => void
SlashRoute
,
function toEpochSeconds(ms: EpochMs): EpochSectoEpochSeconds } from '@seedcord/gateway'; import type { interface RateLimitWindowRateLimitWindow } from '@seedcord/gateway'; const const ONE_DAY_MS: 86400000ONE_DAY_MS = 86_400_000; const const THREE_A_DAY: RateLimitWindowTHREE_A_DAY: interface RateLimitWindowRateLimitWindow = { RateLimitWindow.windowMs: numberwindowMs: const ONE_DAY_MS: 86400000ONE_DAY_MS, RateLimitWindow.limit?: numberlimit: 3 }; @SlashRoute<"award">(...routes: "award"[]): <TCtor>(constructor: AssertSlashRoute<"award", TCtor>) => voidSlashRoute('award') export class class AwardAward extends
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
<'award'> {
public async Award.execute(): Promise<void>execute(): interface Promise<T>Promise<void> { const const member: Usermember = this.SlashHandler<"award", "cached">.options: SlashOptions<"award", "cached">options.getUser: <"member">(name: "member") => UsergetUser('member'); const const key: stringkey =
function buildKey(
    prefix: string,
    ...parts: readonly (string | null | undefined)[]
): string
buildKey
('award', const member: Usermember.User.id: stringid);
const const result: RateLimitResultresult = await this.BaseHandler<ChatInputCommandInteraction<"cached">, Core>.core: Corecore.CoreBase.rateLimiter: IRateLimiterrateLimiter.IRateLimiter.charge(key: string, window: RateLimitWindow): Promise<RateLimitResult>charge( const key: stringkey, const THREE_A_DAY: RateLimitWindowTHREE_A_DAY ); if (const result: RateLimitResultresult.RateLimitResult.limited: booleanlimited) { const const free: EpochSecfree = function toEpochSeconds(ms: EpochMs): EpochSectoEpochSeconds(const result: RateLimitResultresult.RateLimitResult.resetAt: EpochMsresetAt); await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply(`Try again <t:${const free: EpochSecfree}:R>.`); return; } await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply( `Awarded. ${const result: RateLimitResultresult.RateLimitResult.remaining: numberremaining} left today.` ); } }

THREE_A_DAY is a sliding window, so each award counts for one day after it happens. Once the oldest of the three awards is a day old, a fourth award passes.

The second argument is a RateLimitWindow. windowMs is the window's length in milliseconds. limit is how many uses fit in one window. If you leave limit out, the window allows one use. This window leaves it out, so it allows one use per minute.

const ONE_A_MINUTE: RateLimitWindow = { windowMs: 60_000 };

The result

Award reads limited, resetAt, and remaining off its charge result. Both charge and peek return a RateLimitResult.

fieldtypeholds
limitedbooleanwhether the key is at or over its limit
remainingnumberslots left in the window
resetAtEpochMsepoch milliseconds when the earliest slot frees, 0 while the key is under its limit
retryAfterMsnumbermilliseconds until resetAt, 0 while the key is under its limit

Discord's <t:...:R> markup takes seconds. Pass resetAt through toEpochSeconds before you print it.

Keys

The key you pass to charge sets whose uses the window counts. A key is any string. buildKey joins a prefix and your other parts with :. A part that's null or undefined becomes global.

buildKey('award'); // 'award'
buildKey('award', '456'); // 'award:456'
buildKey('award', '123', '456'); // 'award:123:456'
buildKey('award', null); // 'award:global'

Pass as many parts as the limit needs. buildKey('award', guildId, memberId) gives each member their own window in each server.

In a DM the guild id is null, so buildKey writes global in its place. If your key includes the guild id, every DM caller shares that one window. If your command only makes sense in a server, pair that key with GuildOnly to keep DM callers from spending the shared uses.

Cooldown writes its own keys under the cooldown prefix in this store. Give your keys a prefix nothing else uses. If two features build the same key, their uses count against one window.

Reading without charging

A profile card that shows "2 awards left today" needs the count without spending an award. peek takes the same two arguments as charge and doesn't record a use.

async function awardsLeft(memberId: string): Promise<number> {
    const key = buildKey('award', memberId);
    const result = await core.rateLimiter.peek(key, THREE_A_DAY);

    return result.remaining;
}

Note

remaining can be out of date by the time you use it. Another request can charge the same key right after your peek. Show remaining to the caller, and call charge when the use happens.

Cooldown peeks in its check and charges in its commit. Every effect gate separates its check from its commit the same way.

Clearing a key

A window sometimes counts a use that shouldn't have counted, like an award a moderator revoked. reset drops every use recorded against a key, so the next charge starts a fresh window.

async function clearAwards(memberId: string): Promise<void> {
    await core.rateLimiter.reset(buildKey('award', memberId));
}

The default store

Every sample on this page charges the default store. seedcord builds a MemoryRateLimiter for you, which keeps its counts in the memory of one process. A restart clears every window. On a serverless deploy each running copy keeps its own counts.

Set the store config key to replace it. core.rateLimiter then points at your store, for your own charge calls and for every Cooldown gate.

Writing a store

If a limit has to survive a restart, or several copies of your bot share one count, write a store. It wraps a backend like Redis or SQLite behind the same three methods.

Store takes the operations a backend supports as its generic. 'charge' is the one rate limiting uses. A Store<'charge'> carries two fields plus the three IRateLimiter methods.

The generic takes a union. If one backend serves several features, it declares Store<'charge' | 'claim'> and lists both operations in caps. The type rejects an operation in caps that the generic leaves out. It doesn't check that caps lists every operation in the generic, so keep the two matching yourself. Rate limiting reads 'charge' alone.

membertypeholds
kindStoreKindwhich backend this is, one of 'memory', 'sqlite', 'd1', 'turso', 'durable-object', 'redis'
capsReadonlySet<'charge'>the operations it implements, so new Set(['charge']) here
charge(key, window) => Promise<RateLimitResult>records a use when a slot is free
peek(key, window) => Promise<RateLimitResult>reads without recording
reset(key) => Promise<void>clears the key's uses

With implements Store<'charge'>, TypeScript reports any of the five you leave out. In SharedStore, recordUse, readUses, and clearUses stand in for your own database code.

src/SharedStore.ts
function slots(window: RateLimitWindow): number {
    return Math.max(1, window.limit ?? 1);
}

export class SharedStore implements Store<'charge'> {
    public readonly kind: StoreKind = 'sqlite';
    public readonly caps = new Set(['charge'] as const);

    public charge(
        key: string,
        window: RateLimitWindow
    ): Promise<RateLimitResult> {
        return recordUse(key, window.windowMs, slots(window));
    }

    public peek(
        key: string,
        window: RateLimitWindow
    ): Promise<RateLimitResult> {
        return readUses(key, window.windowMs, slots(window));
    }

    public async reset(key: string): Promise<void> {
        await clearUses(key);
    }
}

The type can't check two things for you.

  1. Resolve limit the way slots does. It's optional, and a zero or negative value would block every call.
  2. Make recordUse read and write in one step, such as inside a transaction. Otherwise two requests can each read a free slot before either records a use. Both of them then pass.

Pass a SharedStore as store. Both transports read the key the same way.

src/bot.ts
export const seedcord = new Seedcord({
    store: new SharedStore(),
    bot: {
        clientOptions: { intents: [GatewayIntentBits.Guilds] },
        interactions: {
            path: resolve(import.meta.dirname, './handlers')
        },
        commands: {
            path: resolve(import.meta.dirname, './commands')
        },
        events: { path: null }
    },
    subscribers: { path: null }
});