Cooldowns
Limit how often a command runs with the Cooldown() gate. Covers durations, uses per window, who shares a window, wording the refusal, and keeping windows across restarts.
A command that calls a paid API or posts in a channel needs a limit, or one person can run it dozens of times a minute. By hand, that's a map of last-use times in every handler that needs one, plus a reply telling the caller when to try again.
Cooldown is that limit as a gate. It allows a set number of uses per window and refuses once they're used up, with a card that shows when the caller can try again.
import {
Cooldown,
Gated,
SlashHandler,
SlashRoute
} from '@seedcord/gateway';
@Gated(Cooldown('30m'))
@SlashRoute('feed')
export class Feed extends SlashHandler<'feed'> {
public async execute(): Promise<void> {
await this.reply('Fed.');
}
}Adding options
Start with a duration alone, then add options as the command needs them. Each line below adds one option to the line above it.
// one use per caller every 30 seconds
Cooldown(30);
// three uses per caller every ten minutes
Cooldown('10m', { limit: 3 });
// three uses per server every ten minutes
Cooldown('10m', { limit: 3, per: 'guild' });The sections below cover each option in detail.
The duration
You pass a duration literal as a string, or a number of seconds.
Cooldown('30m'); // thirty minutes
Cooldown(30); // thirty secondsA duration literal is digits followed by one lowercase unit.
| unit | means |
|---|---|
ms | milliseconds |
s | seconds |
m | minutes |
h | hours |
d | days |
Warning
The type accepts any number in front of the unit. The parser accepts whole digits alone. So this compiles, then throws as the module loads.
Cooldown('1.5h');That throw stops your bot from starting. The error's code is GateInvalidCooldownDuration. Write Cooldown('90m') instead.
Everything else the parser rejects throws the same way.
- an uppercase unit, as in
30M - a bare number in a string, as in
'30' - a zero duration, as in
0s
Several uses per window
You set how many uses fit in one window with limit. It defaults to 1.
Cooldown('10m', { limit: 3 });The window is a sliding window, so each use counts for ten minutes after it happens. If someone runs the command at 12:00, 12:02, and 12:05, all three pass. A fourth run at 12:06 is refused. At 12:10 the 12:00 use expires, so the next run after that passes.
Who shares a window
If you leave per out, every caller gets their own window. Pass 'guild' or 'channel' to widen it.
Cooldown('10m', { per: '- user
- guild
- channel
per | one window per | falls back to |
|---|---|---|
'user', the default | caller | one shared window when the source doesn't carry a user |
'guild' | server | one shared window when the source doesn't carry a server |
'channel' | channel | one shared window when the source doesn't carry a channel |
Cooldown('1h', { per: 'guild' });With per: 'guild', every caller outside a server shares one global window. If one person uses the command in a DM, they can use up the window for every other DM caller. Pair it with GuildOnly when your command should only run in a server anyway.
If two handlers use the same settings, they still get separate windows, since the key includes the route. If one handler has two Cooldown calls with different settings, their windows stay separate too.
Wording the refusal
message rewords the refusal. notice replaces it with a Notice you write. If you set both, seedcord uses notice. Both take a function, and seedcord calls it with resetAt. That's the time the caller can use the command again, as an EpochMs number of milliseconds since 1970.
import { Cooldown, toEpochSeconds } from '@seedcord/gateway';
Cooldown('10s', {
per: 'channel',
message: (resetAt) =>
`Slow down. Try again <t:${toEpochSeconds(resetAt)}:R>.`
});Discord's <t:...:R> markup takes seconds, and toEpochSeconds converts it. The default card already prints a relative time.
Cooldown('1m', { notice: (resetAt) => new SlowDown(resetAt) });Putting it together
This handler sets every option that works together. Each server gets three feeds every ten minutes. When a server has used all three, the refusal says when the caller can try again.
import {
Cooldown,
Gated,
SlashHandler,
SlashRoute,
toEpochSeconds
} from '@seedcord/gateway';
@Gated(
Cooldown('10m', {
limit: 3,
per: 'guild',
message: (resetAt) =>
`Out of feeds. Try again <t:${toEpochSeconds(resetAt)}:R>.`
})
)
@SlashRoute('feed')
export class Feed extends SlashHandler<'feed'> {
public async execute(): Promise<void> {
await this.reply('Fed.');
}
}It sets message and leaves notice out, since notice would replace the reworded refusal.
When the slot is charged
Cooldown is an effect gate. It reads the window during the check and records the use only after every gate on the handler has passed. If a later gate refuses, the commit never runs, so the caller keeps their use.
Note
If two requests for the same key arrive at the same moment, both can read a free slot before either records its use. A tight burst can go over limit that way. Every effect gate has this gap between its check and its commit.
Cooldown doesn't guard against it, since bursts that tight are rare. If your command can't allow even one extra use, write your own effect gate. In its commit, call ctx.core.rateLimiter.charge, which records a use only when a slot is free. Throw when result.limited comes back true, and put that gate first so no other commit runs before it.
Keeping windows across restarts
core.rateLimiter holds the cooldown windows and counts them in memory by default. A restart clears every cooldown, and on a serverless deploy each running copy keeps its own counts. A durable store keeps the windows through both.