# 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`](https://docs.seedcord.org/packages/core/latest/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`](https://docs.seedcord.org/packages/types/latest/irate-limiter) 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.

```ts title="src/handlers/Award.ts"
import {
    buildKey,
    SlashHandler,
    SlashRoute,
    toEpochSeconds
} from '@seedcord/gateway';

import type { RateLimitWindow } from '@seedcord/gateway';

const ONE_DAY_MS = 86_400_000;
const THREE_A_DAY: RateLimitWindow = {
    windowMs: ONE_DAY_MS,
    limit: 3
};

@SlashRoute('award')
export class Award extends SlashHandler<'award'> {
    public async execute(): Promise<void> {
        const member = this.options.getUser('member');
        const key = buildKey('award', member.id);
        const result = await this.core.rateLimiter.charge(
            key,
            THREE_A_DAY
        );

        if (result.limited) {
            const free = toEpochSeconds(result.resetAt);
            await this.reply(`Try again <t:${free}:R>.`);
            return;
        }

        await this.reply(
            `Awarded. ${result.remaining} 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`](https://docs.seedcord.org/packages/types/latest/rate-limit-window). `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.

```ts
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`](https://docs.seedcord.org/packages/types/latest/rate-limit-result).

{/* prettier-ignore-start */}

| field          | type      | holds                                                                                 |
| -------------- | --------- | ------------------------------------------------------------------------------------- |
| `limited`      | `boolean` | whether the key is at or over its limit                                               |
| `remaining`    | `number`  | slots left in the window                                                              |
| `resetAt`      | `EpochMs` | epoch milliseconds when the earliest slot frees, `0` while the key is under its limit |
| `retryAfterMs` | `number`  | milliseconds until `resetAt`, `0` while the key is under its limit                    |

{/* prettier-ignore-end */}

Discord's `<t:...:R>` markup takes seconds. Pass `resetAt` through [`toEpochSeconds`](https://docs.seedcord.org/packages/utils/latest/to-epoch-seconds) before you print it.

## Keys

The key you pass to `charge` sets whose uses the window counts. A key is any string. [`buildKey`](https://docs.seedcord.org/packages/rate-limiter/latest/build-key) joins a prefix and your other parts with `:`. A part that's `null` or `undefined` becomes `global`.

```ts
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`](https://docs.seedcord.org/packages/core/latest/guild-only) 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.

```ts
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](/checks/effect-gates) 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.

```ts
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`](https://docs.seedcord.org/packages/rate-limiter/latest/memory-rate-limiter) 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`](https://docs.seedcord.org/packages/types/latest/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`](https://docs.seedcord.org/packages/types/latest/irate-limiter) 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.

{/* prettier-ignore-start */}

| member   | type                                                                      | holds                                                                                                  |
| -------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `kind`   | [`StoreKind`](https://docs.seedcord.org/packages/types/latest/store-kind) | which backend this is, one of `'memory'`, `'sqlite'`, `'d1'`, `'turso'`, `'durable-object'`, `'redis'` |
| `caps`   | `ReadonlySet<'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                                                                                  |

{/* prettier-ignore-end */}

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.

```ts title="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.

```ts title="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 }
});
```
