Just a quick notice: This will not get your webhooks working. Rather, it will greatly help prevent future blocks from Discord, and greatly improve your webhook speeds by preventing Discord from issuing extra penalty limits on you for repeated request errors.
Rate Limited Webhook API for Discord
Hello!
I wanted to write this quick resource here to encourage the use of proper rate limits for Discord Webhooks. Itâs implemented in a future-proof way and supports various fallbacks and rate limiting methods to make it as effective as possible.
Discord issues additionally penalty limits if you donât follow their rate limits properly, so, once the block is lifted this will ensure youâre following their requested rate limits, even if they change in the future.
Download
Source code
-- Services
local HttpService = game:GetService("HttpService")
-- Module
local DiscordWebhook = {
-- Rate limit states
State = {
Default = {
Limit = 1,
Remaining = 1,
ResetTime = os.time(),
ResetDelay = 1
}
},
Buckets = {},
CleanupTime = os.time(),
Discarded = {} -- Discarded Webhook URLs (404s)
}
-- Default state metatable
DiscordWebhook.DefaultState = {__index = DiscordWebhook.State.Default}
-- How many seconds buckets should be kept in memory for before being cleared (Keep 30-60 seconds, super low values are *not* better)
local BUCKET_LIFETIME = 30
-- Crude domain check to make sure you're not accidentally requesting non discord URLs
function DiscordWebhook:CheckDomain(url)
return string.find(url, "discord.com", nil, true) or not string.find(url, "discordapp.com", nil, true)
end
-- Cleans up rate limit buckets
function DiscordWebhook:CleanupBuckets()
-- Return if cleaned up too recently
if os.time() - self.CleanupTime < BUCKET_LIFETIME then
return
end
local markedCleanupTime = false
-- Check state expiration and clean up any old states outside the lifetime
local states = self.State
for bucketId, state in pairs(states) do
-- Clear expired buckets
local expires = state.Expires
if expires and os.time() > expires then
states[bucketId] = nil
end
end
-- Clear unused buckets
local buckets = self.Buckets
for webhookUrl, bucketId in pairs(buckets) do
-- Clear removed buckets
if not states[bucketId] then
buckets[webhookUrl] = nil
end
end
self.CleanupTime = os.clock()
end
-- Drop in replacement for HttpService:PostAsync
function DiscordWebhook:PostAsync(webhookUrl, data)
-- Fall back to real PostAsync for non Discord URLs
if not self:CheckDomain(webhookUrl) then
return HttpService:PostAsync(webhookUrl, data)
end
-- Make the request
local response = self:RequestAsync({
Url = webhookUrl,
Method = "POST",
Body = data
})
return response.Body
end
-- Drop in replacement for HttpService:RequestAsync
function DiscordWebhook:RequestAsync(request)
if request and type(request) == "table" then
local webhookUrl = rawget(request, "Url")
if self:CheckDomain(webhookUrl) then
-- If the URL was discarded, return the cached response
local discardedResponse = self.Discarded[webhookUrl]
if discardedResponse then
return discardedResponse
end
-- Make a request via HttpService
local success, response = pcall(function()
-- Repeatedly retry until response ready
while true do
-- Wait for the lock time if locked
if self.LockedFor then
wait(self.LockedFor)
end
-- Bucket id
local bucketId = self.Buckets[webhookUrl]
if bucketId then
local curState = self.States[bucketId]
-- If no remaining requests
if curState.Remaining == 0 then
-- Wait until the reset time & reset the request count
local resetTime = curState.ResetTime
if resetTime then
wait(os.time() - resetTime)
-- Reset the request count to the limit
curState.Remaining = curState.Limit
-- Increase the reset time by the reset delay
curState.ResetTime += curState.ResetDelay
end
end
-- Subtract a request from the count
curState.Remaining -= 1
end
-- Clean up expired buckets
self:CleanupBuckets()
-- Make a request
local response = HttpService:RequestAsync(request)
-- Handle various error codes
if response.StatusCode == 404 then
-- Discard 404s as per Discord request
self.Discarded[webhookUrl] = response
elseif response.StatusCode == 401 or response.StatusCode == 403 then
-- Discard 401s and 403s since they mean the webhook is useless
self.Discard[webhookUrl] = response
elseif response.StatusCode == 429 then
-- We're being rate limited, make sure to respect Retry-After header
-- Get Retry-After
local retryAfter = tonumber(response.Headers["Retry-After"])
-- Set lock state
self.LockedFor = retryAfter
-- Wait for retry period
wait(retryAfter)
-- Clear lock state
self.LockedFor = nil
-- Do a retry
continue
else
-- Get response headers
local headers = response.Headers
-- Get rate limit header info
local bucketId = headers["X-RateLimit-Bucket"]
if bucketId then
local limit = tonumber(headers["X-RateLimit-Limit"])
local remaining = tonumber(headers["X-RateLimit-Remaining"])
local resetTime = tonumber(headers["X-RateLimit-Reset"])
local resetDelay = tonumber(headers["X-RateLimit-Reset-After"])
-- Get current state or assign new one
local curState = self.State[bucketId]
if not curState then
curState = setmetatable({
Expires = os.time() + BUCKET_LIFETIME
}, self.DefaultState)
self.State[bucketId] = curState
end
-- Update state
curState.Limit = limit
curState.Remaining = remaining
curState.ResetTime = resetTime
curState.ResetDelay = resetDelay
-- Store bucket id
self.Buckets[webhookUrl] = bucketId
-- Perform rate limiting on the current thread
if remaining == 0 then
-- Set lock state
self.LockedFor = resetDelay
-- Wait for reset delay
wait(resetDelay)
-- Clear lock state
self.LockedFor = nil
end
end
end
return response
end
end)
-- Use a fallback response on failure
if not success then
return {
Success = false,
StatusCode = 555,
StatusMessage = "DiscordWebhook fallback error (" .. tostring(response) .. ")",
Headers = {},
Body = "{}"
}
end
return response
end
end
-- Fall back to real RequestAsync
return HttpService:RequestAsync(request)
end
return DiscordWebhook
Usage
Using this tool is simple.
Just require it like so, and replace your HttpService:PostAsync
or HttpService:RequestAsync
usage. (Please note that GetAsync and other HttpService items are not implemented since they arenât used for webhook posting, just those two are)
local DiscordWebhook = require(path.To.DiscordWebhook)
DiscordWebhook:PostAsync(webhookUrl, webhookData)
-- or
DiscordWebhook:RequestAsync(webhookRequest)
The module will automatically attempt to detect if you accidentally use a non Discord url, and pass requests through normally.
Technical Details
(See https://discord.com/developers/docs/topics/rate-limits)
The module keeps track of each bucket by its ID as specified by Discord. Every bucket can have a unique state and the bucket id is cached per webhook. Buckets and cached webhook urls are discarded after a set amount of time (default is 30 seconds).
All states are updated after each webhook request made to Discord, as all information about the rate limit state is returned in addition to timing info. States are also calculated, so, if a request fails, the state should remain up to date. Previous timing info will be used in that case.
Discord requests that requests returning a 404 code are discarded and requests are not repeated. This module respects this by caching the initial response and returning it on duplicate urls, as well as implementing this behaviour for 401s and 403s.
This module additionally respects the standard error 429 Retry-After
header as a fallback to the standard Discord limits, and performs a retry for the request correctly.