# The rate limiter

Count uses against any key you build with the rate limiter, and pass a durable kv driver to keep the counts through a restart.

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://seedcord.org/docs/packages/core/latest/functions/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://seedcord.org/docs/packages/types/latest/interfaces/irate-limiter) is the same rate limiter `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://seedcord.org/docs/packages/types/latest/interfaces/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://seedcord.org/docs/packages/types/latest/interfaces/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://seedcord.org/docs/packages/utils/latest/functions/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://seedcord.org/docs/packages/core/latest/functions/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://seedcord.org/docs/packages/core/latest/functions/guild-only) to keep DM callers from spending the shared uses.

`Cooldown` writes its own keys under the `cooldown` prefix in this rate limiter. 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](https://next.seedcord.org/guide/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));
}
```

## Keeping counts across restarts

The rate limiter saves its windows through the driver you pass as `kv` in the bot's config. Without one, seedcord uses a [`MemoryDriver`](https://seedcord.org/docs/packages/core/latest/classes/memory-driver), which keeps them in the memory of one process. A restart clears every window. On a serverless deploy each running copy keeps its own counts, and on [Workers](https://next.seedcord.org/guide/build/workers/) each isolate is one copy.

To keep the windows through a restart, pass a driver over a file or a database, as [Drivers](https://next.seedcord.org/guide/built-ins/drivers/) shows. Every `charge` on this page and every `Cooldown` gate then writes through it.

> **Warning**
>
> If two copies of your bot share one database, both can read a free slot before either records a use. Both charges then pass, so the key ends up with more uses than its `limit`.
