tadpole/rest/rate_limit
Per-bucket rate-limit state for REST: the numbers responses have reported and the waits they imply. Values come from response headers and 429 bodies only — no hardcoded table, because Discord changes buckets without notice and the headers are the only truth available.
Pure: no processes, no clock. Time enters as integer arguments; the
caller owns the clock and the route -> bucket map (associate).
Stability: Growing.
tadpole/rest/execute owns the bookkeeping; this
module owns the arithmetic. Nothing here sleeps, sends, or remembers.
When you reach for this
Directly, almost never — unless you are writing your own executor or
a test that wants exact wait math. BucketState is a public record
on purpose, same status as RestRequest: plain data, nothing here is
an invariant worth guarding.
wait_ms: what now_ms means
wait_ms(state, now_ms) answers “how long should the next request
on this bucket hold off?”. The parameter name misleads everyone
once: now_ms is NOT a clock reading. It is the time elapsed since
the response that last updated this state — 0 immediately after
update, and whatever the caller’s clock says after that. Tadpole
never reads a clock; the caller stamps each response and subtracts.
Priority order: a 429’s retry window first (retry_after_ms minus
elapsed), then a known-empty bucket (remaining == 0) counting down
reset_after_ms, else 0. That final 0 is deliberate: with no
headers observed, fire and accept the risk of another 429 —
inventing a wait would stall buckets Discord never limited.
Route masking
route_key masks every all-digit path segment of 15+ characters to
:id, producing “GET /channels/:id/messages”. That is a grouping
key, not Discord’s bucket identity: it folds every channel id into
one key, so pre-bucket state mixes traffic across major parameters —
a wait learned on channel A gates channel B too. Discord’s real
per-id bucketing is visible only in the X-RateLimit-Bucket header,
which is what associate binds.
associate: re-keying
Until a response names a bucket, state lives under the masked route
key. associate binds route key -> bucket id once
X-RateLimit-Bucket appears; from that response on, the bucket id is
the state key and lookups should prefer it. A later response naming
a different bucket simply overwrites the binding — Discord
reshuffles buckets sometimes. No bucket header, no change.
Failure modes
Cannot fail: no I/O, and nothing parses but what the caller already
parsed. Degenerate inputs degrade instead — update with no parsed
headers leaves the state untouched; a half-headered response
downgrades the fields it omits to None (the response is the whole
truth). Only a 429 sets the retry window; a Retry-After on any
other status is ignored.
See also
tadpole/rest/execute— the caller that juggles this statetadpole/rest— the header parsing that feedsupdate
Types
A public record on purpose: states are plain data the caller juggles per bucket, same as RestRequest. Nothing here is an invariant worth guarding.
pub type BucketState {
BucketState(
limit: option.Option(Int),
remaining: option.Option(Int),
reset_after_ms: option.Option(Float),
retry_after_ms: option.Option(Int),
bucket: option.Option(error.BucketId),
)
}
Constructors
-
BucketState( limit: option.Option(Int), remaining: option.Option(Int), reset_after_ms: option.Option(Float), retry_after_ms: option.Option(Int), bucket: option.Option(error.BucketId), )
Values
pub fn associate(
routes: dict.Dict(String, error.BucketId),
key: String,
response: rest.RestResponse,
) -> dict.Dict(String, error.BucketId)
Bind a route key to the X-RateLimit-Bucket id the response carried.
No bucket header, or no parsed headers — the dict comes back unchanged.
The dict belongs to the caller and stays plain data: key it by
route_key output, check it before each request, and from the first
bound response onward use the bucket id as the state key. A response
naming a different bucket than before simply overwrites the binding;
Discord reshuffles buckets sometimes.
pub fn route_key(method: String, path: String) -> String
“:id.
This is a grouping key, not Discord’s bucket identity. Masking folds
every channel (or guild, or webhook) id into one key, so pre-bucket
state mixes traffic across major parameters — a wait learned on channel
A gates channel B too, and a 429 on B can surprise A’s state. Discord’s
real per-id bucketing is only visible in the X-RateLimit-Bucket
header; associate binds it once a response has been seen, and lookups
should prefer the bucket id from then on.
rate_limit.route_key("GET", "/channels/862474250334586890/messages")
// -> "GET /channels/:id/messages"
pub fn update(
state: BucketState,
response: rest.RestResponse,
) -> BucketState
Fold one response’s rate-limit headers into the state.
Any header Discord omitted degrades that field to None: the response
is the whole truth, so a half-headered response downgrades what was
known. A response with no parsed rate-limit headers at all (the caller
skipped rest.parse_rate_limit_headers) leaves the state untouched
instead of erasing it.
Only a 429 sets the retry window: Retry-After (seconds, converted to
ms) first, then X-RateLimit-Reset-After, else None. A Retry-After
on any other status is ignored.
pub fn wait_ms(state: BucketState, now_ms: Int) -> Int
How long the next request on this bucket should hold off, in ms. 0 means fire now.
now_ms is time elapsed since the response that last updated this
state: 0 right after update, growing as the caller’s clock runs.
Tadpole never reads a clock, so the caller stamps each response and
does that subtraction itself.
Priority: a 429’s retry window first, then a known-empty bucket
(remaining 0) counting down reset_after, else 0. That final 0 is
optimistic — with no headers observed we fire and accept the risk of
another 429, because inventing a wait would stall buckets Discord never
limited. Windows also anchor when update runs, so network latency
makes waits slightly longer than strictly needed; that skew errs safe.