A request is an act of communication. Suppose that we have a conversation:
- I send you a message.
- You receive the message.
- You process the message.
- You send a new message back to me.
This conversation occurs similarly between our computers. Whenever you enter a URL in your browser, a client (e.g., web browser) sends a request to a web server that processes the request, then sends another back.
To communicate better, humans create protocols that others adhere to during conversation:
- The HTTP protocol is used to send and receive resources (data).
- A REST API is a style used to communicate resources.
Discord uses an HTTP REST API to transfer information between its servers and your client (e.g., Discord Bot).
In the context of a network, a client is a computer that receives information, while a server is a computer that serves information. This distinction can be confusing because a computer with a specialized use case is colloquially referred to as a server.
In the context of a Discord Bot, the client refers to the server (computer) that your bot application runs on, while the server refers to Discord's server(computer).
Disgo provides a simple way to send requests from a Discord Bot.
Suppose that your Discord Bot sends a request to Discord:
- Disgo sends a request to Discord's API Server(s).
- Discord's API Server processes the sent request based on several factors (e.g., headers, endpoint, URL query string, data).
- Discord's API Server returns a response with a status code and other data to the Discord Bot.
- Disgo handles the response to provide you with the requested data.
Disgo automatically handles request rate limits so your bot isn't blacklisted from Discord.
A request is sent using the Send(bot)
function.
Disgo is a 1:1 API, meaning the objects defined in the Discord API Documentation are directly represented in Disgo. For example, a CreateGlobalApplicationCommand
request can be prepared and sent using the following code:
// Create a Create Global Application Command request.
request := disgo.CreateGlobalApplicationCommand{
Name: "main",
Description: "A basic command",
}
// Send the Global Application Command to Discord using the bot.
newCommand, err := request.Send(bot)
if err != nil {
log.Printf("failure sending command to Discord: %v", err)
}
A request retry occurs when your request fails to receive a response (from Discord) due to an error. You can set the amount of retries per request by setting the Client.Config.Request.Retries
field (default: 1).
bot.Config.Request.Retries = 1
A request timeout represents the amount of time a request will wait for a response (from Discord). You can set a bot's request timeout from the Client.Config.Request.Timeout
field (default: 1s).
bot.Config.Request.Timeout = time.Second * 15
fasthttp.ErrTimeout
is returned from timed out requests.
Servers use rate limits to prevent spam, abuse, and service overload. A rate limit defines the speed at which a server can handle requests (in requests per second).
While there are many rate limit strategies a server may employ, Google Architecture Rate Limiting Strategies explains the most common cases. Discord enforces multiple rate limit strategies depending on the data sent to the server.
The Discord rate limit strategies for requests include:
- Global (Requests)
- Per Route (Requests)
- Per Resource (Requests)
Disgo makes adhering to Discord's Rate Limits easy by providing a customizable rate limiter:
- Use the builtin
RateLimit
implementation or develop your own by implementing theRateLimiter interface
(which stores Buckets). - Set the
Client.Request.RateLimiter
to customize how rate limiting works for HTTP Requests. - Set entries in the
RateLimitHashFuncs
map to control how a route is rate limited (per-route, per-resource, etc). - Configure the
RateLimit.DefaultBucket
to control the behavior for requests that are sent without a known rate limit.
Discord maintains two main rate limit strategies: Per-Route and Per-Resource.
A per-route (user) rate limit refers to a rate limit that applies at the route level and for the user. Specifically, a route is a combination of an HTTP Method and Endpoint (e.g., GET /guilds/id
).
Users and Bots must adhere to per-route rate limits.
A per-resource (shared) rate limit refers to a rate limit that applies at the resource level and for a resource (e.g., guild
). It's not recommended to adhere to per-resource rate limits.
BOTH rate limits can be applied to the same route.
Discord expects the bot (user) to send a request until it succeeds (as many times as necessary). So the per-route (user) rate limit is used to limit the number of requests a user sends per second, while the per-resource (shared) rate limit is used to limit the usage of a resource (to control the overall load on Discord's servers).
Per-resource routes depend on factors your bot can NOT keep track of. So the bot is only required to adhere to per-route rate limits. In addition, experiencing 429 Status Codes
with the shared
Rate Limit Scope Header does NOT count against you.
Disgo helps the developer implement the above behavior through the Request.RetryShared
field. When the Client.Request.RetryShared
field of a bot is set to true
(default), the bot will send a request — within the per-route rate limit — until one is successful or until one experiences a non-shared 429 status code.
In any other case, the Request.Retries
field can be set to control the number of times a request may be retried upon any failure. Implementing per-user per-resource route rate limits is possible using the RateLimitHashFuncs
map (see example), but not recommended.
Discord utilizes a Token Bucket Rate Limit Algorithm for their rate limits. Unfortunately, Discord's specific implementation of this rate limit strategy does NOT allow the application to determine the rate limit of a route (HTTP Method + Endpoint) until a request with that route is sent. This results in a dilemma where you must determine whether to sacrifice performance or safety to send specific requests (before those requests have ever been sent).
A Default Bucket is used when a rate limit is NOT yet known by the application: In other words, when a request for a route has NEVER been sent.
In Disgo, the RateLimit.DefaultBucket
field represents the Default Rate Limit Bucket used for requests which operate at the per-route level. However, configuring Default Buckets for per-resource (n) routes is also possible (see example).
Set the DefaultBucket.Limit
field-value to control how many requests of a given route can be sent (per second) BEFORE the actual Rate Limit Bucket of that route is known.
Suppose that Route A (POST /A
) is constrained by a Global Rate Limit of 50 requests per second and a Per Route Rate Limit of 25 requests per second. When a bot (application) starts, it opts to send these requests as often as needed.
No problems occur while the bot is small since it always sends less than 25 requests per second. However, the bot grows and eventually receives 429 Too Many Request
responses upon startup.
What went wrong?
Instead of sending <=25 requests upon startup, the bot sends 40. While this adheres to the Global Rate Limit, it does NOT adhere to the Per Route Rate Limit.
There are two valid solutions to the above issue:
- Send a request synchronously (blocking; in-order) until the rate limit is known. In other words, require the bot to send one request and wait until the response is received to be able to send requests concurrently (non-blocking; asynchronous).
- Send as many requests as needed (while adhering to the Global Rate Limit), and resend the requests for responses that receive
429 Too Many Request Status Codes
.
In either case, the bot will eventually successfully send all the required requests. However, the first case will take 3 batches (1 + 25 + 14), while the second case will only take 2 batches (25 + 15); at the cost of 15 429 Status Codes
.
Employing the second strategy is more efficient but could be costly. In an actual application, there are other implications to failed requests that we haven't even considered.
Receiving 10,000 (user) 429 Status Codes
in 10 minutes results in a Cloudflare Ban for approximately one hour.
Disgo solves the problem described in the above example using configurable Rate Limits and Default Buckets:
- When a request's rate limit is unknown, Disgo will only send as many requests as the configured Default Bucket allows (1 by default).
- Once the request receives a response, the Default Bucket will be discarded and replaced by the request's actual Rate Limit Bucket (or nil).
This implementation gives you multiple ways to address the issue described above.
If you want to ensure that every request at the route level initially sends 25 requests per second, you can set the DefaultBucket
of the Client.RateLimiter
.
bot.Config.Request.RateLimiter.SetDefaultBucket(&disgo.Bucket{
Limit: 25,
Remaining: 25,
})
If you want to ensure that ONLY Route A
initially sends 25 requests per second, you can initialize a RateLimiter
with that Route ID Bucket
.
// create a Client using a Default Configuration.
bot := disgo.Client{
Config: disgo.DefaultConfig()
...
}
// set Route A to a Bucket (ID "temp") in the Client's initialized Request Rate Limiter.
bot.Config.Request.RateLimiter.SetBucketID("A", "temp")
bot.Config.Request.RateLimiter.SetBucketFromID("temp", &disgo.Bucket{
Limit: 25,
Remaining: 25,
})
// optional: set other Routes (i.e "B") to Route A's Bucket using the SetBucketID function.
bot.Config.Request.RateLimiter.SetBucketID("B", "temp")
NOTE: "A"
is used as the ID for Route A in this example. Use the Route ID showcased in request_send.go
for actual requests.
When Route A
refers to a per-resource route, a Default Bucket can be configured by using the Route ID
of the parent route. As an example, Route A/Guild2/Channel3
(Route ID A/Guild2
, ResourceID Channel3
) uses the Default Bucket at A/Guild2
(if it exists). This is possible with two steps.
1. Configure the hashing function for Route A/Guild2/Channel3
to use A/Guild2
as a Route ID. This step can also be used to change the rate limit algorithm of any route.
disgo.RateLimitHashFuncs[disgo.RouteIDs["A"]] = func(routeid string, parameters ...string) (string, string) {
return routeid + parameters[0], parameters[1] // where parameters is [Guild2, Channel3]
}
2. Set the default bucket for Route A/Guild2
.
bot.Config.Request.RateLimiter.SetBucketID("AGuild2", "AGuild2BucketID")
bot.Config.Request.RateLimiter.SetBucketFromID("AGuild2BucketID", &disgo.Bucket{
Limit: 25,
Remaining: 25,
})
This results in the first requests of Route A/Guild2/Channel3
, Route A/Guild2/Channel4
, Route A/Guild2/Channel...
to initially use a Rate Limit Bucket that allows 25 requests per second.
When you configure both buckets (Route, Parent Route, and Route ID), Route A
is ONLY assigned to the Route ID
Default Bucket, since Route A
already has a "known" bucket. This known bucket will be updated upon receiving a response — that results from a Route A
request — from Discord.