Skip to main content

Fork fixes

Our fixes branch starts from the original project's last commit (97b3251, archived in March 2026). We used the other forks (DraftBot, davfsa, Melonly, LorittaBot, TicketsBot, PluralKit, WelcomerTeam…) as a catalogue of known bugs, not as code to merge: every bug was checked on the original code, against Discord's documentation and a capture of real Discord responses, then fixed with a test that fails without the fix.

The full list, with the evidence and where each bug was found, is in FIXES.md.

Build​

  • #1 Did not build with Go 1.23+: the pinned golang.org/x/net used a go:linkname newer toolchains reject.

Rate limits​

  • #2 The global lock did nothing: written after a global 429, it was never read, and requests kept flowing into the limit.
  • #3 Any 404 under /webhooks/ locked the route as an unknown webhook, including for a deleted webhook message.
  • #5 Every /channels/{id} request shared one queue, across all channels: locking many channels during a raid serialized them, and one exhausted channel put them all to sleep.
  • #6 /guilds/{id}/channels shared one queue across every guild.
  • #7 An interaction's followup and original response ran in two queues, although Discord counts them together.
  • #8 Interactions waited on and spent the global limit, which Discord exempts them from.
  • #9 Renaming a channel or changing its topic shared the queue of every channel edit, and after its sub-limit 429, only the bucket's short reset was slept.
  • #12 The global limit was inferred from max_concurrency, an undocumented heuristic that costs a /gateway/bot request: off by default, replaced by BOT_RATELIMIT_OVERRIDES.
  • #14 The dedicated queue for deleting messages older than 14 days never applied, because of a comparison against the wrong path segment.
  • #15 The 429 the proxy makes up sent x-ratelimit-after, a header Discord never sends, instead of X-RateLimit-Reset-After.

Requests and safety​

  • #4 The upstream URL was rebuilt from the decoded path: the keycap emoji #️⃣ turned its # into a fragment, and the reaction reached Discord truncated. The hop between cluster nodes had the same bug.
  • #10 Invalid requests were not tracked, although 10,000 in ten minutes gets the IP banned: see Metrics.
  • #11 strings.SplitN(url, "?", 1) never split anything: a long query string value could exempt a request from the 401 lock.

Configuration​

  • #13 The Discord address was hardcoded: DISCORD_URL lets you target a simulator.
  • #16 A node advertised the address memberlist guessed, sometimes unreachable inside a container: CLUSTER_ADVERTISE_ADDR.

What we did not take​

  • davfsa's token bucket model, which sends again as soon as a token is back: its detection had no tests, and a client built on @discordjs/rest waits for the full refill anyway. The proxy waits the time Discord announces, which can be too long, never too short, whatever the bucket's model.
  • The 250 ms pause after each reaction (DraftBot, PluralKit, inherited from eris): no capture supports it yet.
  • Concurrent requests on one bucket (davfsa): a @discordjs/rest client sends one request per bucket at a time.

The original queues ignore the HTTP method, which makes them coarser than Discord's buckets: that can make requests wait longer than needed, never send into a limit, so we keep them that way.