Rate Limiting
What retries automatically, always
Section titled “What retries automatically, always”Every outbound call in cordless retries on a 429, honoring the retry_after Discord sends back. This needs no configuration and involves no infrastructure. It covers three separate groups of calls, which behave differently:
send_message,edit_message,delete_message, and the other bot-token REST calls (role grants, webhook management): retry for up to 30 seconds before giving up. These are the only calls that also participate in the opt-in coordination described below.- Interaction followups (
ctx.send()on a deferred command,ctx.send_followup(),ctx.delete_original()): retry locally, up to 3 attempts. Discord buckets these routes by the interaction’s own token, so a given interaction essentially never shares a bucket with another invocation. There is nothing for cross-invocation coordination to do here, soratelimit = truehas no effect on these calls at all. - Webhook calls (
execute_webhook,edit_webhook_message,delete_webhook_message, sending through a Discord webhook URL): also retry locally, up to 3 attempts. A webhook’s id+token pair is its own credential and its own bucket, same reasoning as interaction followups.ratelimit = truehas no effect on these either.
Sequential sends within one Lambda invocation (e.g. await ctx.send(...) followed by a couple of await bot.send_message(...) calls) don’t hit 429s in practice, because the network round trip between one send and the next already spaces them out under most buckets. Concurrent sends within the same invocation are not protected the same way: firing several bot.send_message() calls at once via asyncio.gather to the same channel will race each other and can 429 repeatedly, since nothing paces them relative to one another. If a handler needs to send several messages to the same channel, await them one at a time rather than gathering them concurrently.
Cross-invocation coordination (opt-in, send_message family only)
Section titled “Cross-invocation coordination (opt-in, send_message family only)”Sequential pacing only works within a single invocation. Separate concurrent Lambda invocations, for example a mass announcement handled across many simultaneous cold starts, are isolated processes with no shared memory, so one invocation retrying after a 429 has no way to tell another, running at the same instant, that the same bucket is about to be exhausted. To coordinate across invocations, opt in:
[deploy]ratelimit = trueWith this set, cordless deploy provisions a small DynamoDB table, grants your bot’s Lambda role read/write access scoped to just that table, and wires it into your function’s environment. Nothing changes in your code, and again, this only affects send_message/edit_message/delete_message and friends, not interaction followups or webhook calls.
cordless tracks Discord’s X-RateLimit-* response headers locally within each warm Lambda container, and consults the shared table when that local state is missing (a cold start) or already close to the limit, not before every request. Normal traffic never touches DynamoDB; only requests near an actual limit do. Every DynamoDB call fails open, so a DynamoDB outage never blocks sending a message.
Current limitations, measured against a live bot, not just in theory:
- This reduces collisions across concurrent invocations but does not eliminate them. A genuinely simultaneous burst (e.g. 20 separate cold invocations all sending to the same channel at once) still produces a meaningful number of 429s even with coordination enabled. Checking shared state before sending is inherently racy for truly simultaneous callers, since checking and committing to send aren’t a single atomic step. Every 429 still resolves via the retry budget above (nothing gets dropped), but there’s real wasted latency under heavy simultaneous concurrency. Only a strict per-call lock could close this gap, which isn’t implemented, on purpose, because it would tax every outbound call with a mandatory DynamoDB round trip rather than only the ones near a limit. When a wait does happen, cordless logs it (
rate limit: waiting 1.23s on POST /channels/.../messages), visible incordless logs, so it isn’t purely silent latency you’d only notice by accident. - Coordination is scoped per bucket (effectively per channel), not global. Discord also caps each bot token at roughly 50 requests/second across every route combined, separate from any single channel’s own limit. Fanning out to many different channels (e.g. 50+ guilds in the same second) can hit that global cap even though no single channel’s bucket looks busy; cordless doesn’t track or protect against that today. The retry budget still recovers from the resulting 429s; there’s just no proactive pacing against the global limit specifically.
Cost is pay-per-request DynamoDB pricing, negligible at typical bot volume, and still only a few dollars a month even at very high message volume. Writes only happen when a bucket is actually near its limit or just 429’d, not on every send, so someone spamming a channel doesn’t multiply DynamoDB writes beyond what Discord’s own rate limit already allows through: the write volume is bounded by real rate-limit events, not by call volume.
cordless destroy removes the table automatically as long as ratelimit = true is still set in cordless.toml at the time you run it. See the CLI Reference for the full deploy/destroy flag list.
