glyde/rest/limiter
Discord’s rate limiter, as a pure state machine.
now_ms is an argument, not a timer, and must be monotonic. Deadlines are
absolute, so Tick is idempotent and a Wake that is early, late,
duplicated or lost costs latency and never liveness.
let submit = limiter.Submit(limiter.Ticket(1), rest.route(call))
let #(state, out) = limiter.step(state, now_ms: now, input: submit)
// out is [Send(Ticket(1))]: send it, then hand the answer back.
let outcome = headers.to_limiter_outcome(headers.outcome(status, hs, body))
let answer = limiter.Settled(limiter.Ticket(1), outcome)
let #(state, out) = limiter.step(state, now_ms: later, input: answer)
Types
What one response said about a bucket. None means the counter did not
arrive, so the field it names keeps whatever it already held.
pub type Counters {
Counters(
bucket: option.Option(String),
limit: option.Option(Int),
remaining: option.Option(Int),
reset_after_ms: option.Option(Int),
)
}
Constructors
-
Counters( bucket: option.Option(String), limit: option.Option(Int), remaining: option.Option(Int), reset_after_ms: option.Option(Int), )Arguments
- bucket
-
Names the bucket several routes share. Absence does not invalidate the counters beside it.
pub type Input {
Submit(ticket: Ticket, route: route.Route)
Retry(ticket: Ticket, route: route.Route)
Settled(ticket: Ticket, outcome: Outcome)
Abandoned(ticket: Ticket)
Tick
}
Constructors
-
Submit(ticket: Ticket, route: route.Route) -
Retry(ticket: Ticket, route: route.Route)A 429’d call being retried. Goes to the front of the pending list, so a retry does not reorder the caller’s writes behind calls queued after it.
-
-
Abandoned(ticket: Ticket)A permitted call abandoned with no response. A probing bucket has no deadline, so without this a dropped probe strands its route forever.
-
TickRe-evaluate. Idempotent and always safe.
pub type Limits {
Limits(
global_per_second: Int,
invalid_budget: Int,
max_pending: Int,
)
}
Constructors
-
Limits( global_per_second: Int, invalid_budget: Int, max_pending: Int, )Arguments
- global_per_second
-
Requests per second across all buckets. Discord documents 50.
- invalid_budget
-
401/403/429 tolerated per ten minutes before glyde stops sending. Discord bans at 10_000; the default leaves headroom.
pub type Notice {
BucketLearned(route_key: String, bucket: String)
GloballyFrozen(for_ms: Int)
InvalidBudgetLow(remaining: Int)
SharedThrottle(bucket: String, for_ms: Int)
}
Constructors
-
BucketLearned(route_key: String, bucket: String) -
GloballyFrozen(for_ms: Int) -
InvalidBudgetLow(remaining: Int) -
SharedThrottle(bucket: String, for_ms: Int)
What one response taught the limiter. glyde/rest/headers turns a real
response into one of these, so the machine itself never reads a header.
pub type Outcome {
Learned(counters: Counters)
Throttled(
retry_after_ms: Int,
scope: Scope,
bucket: option.Option(String),
)
Rejected(status: Int)
Opaque
}
Constructors
-
Learned(counters: Counters)A response carrying the usual
x-ratelimit-*counters. -
Throttled( retry_after_ms: Int, scope: Scope, bucket: option.Option(String), ) -
Rejected(status: Int)401 or 403.
-
OpaqueAnything else, including transport failure. Frees the bucket and teaches the limiter nothing.
pub type Refusal {
InvalidBudgetSpent
Backlogged
DuplicateTicket
}
Constructors
-
InvalidBudgetSpentThe invalid-request budget is spent. Sending would risk a Cloudflare ban on the whole IP, which takes down every bot sharing it.
-
BackloggedMore than
max_pendingcalls already waiting. -
DuplicateTicketThis ticket is already queued or already out. Accepting it would drop the first record of the two, leaking the bucket slot it holds.
Whose fault the 429 was.
pub type Scope {
UserScope
SharedScope
GlobalScope
}
Constructors
-
UserScope -
SharedScopeSomebody else’s traffic on a resource we share. Discord does not count these against the invalid-request budget, and neither do we.
-
GlobalScope
Values
pub fn step(
limiter: Limiter,
now_ms now_ms: Int,
input input: Input,
) -> #(Limiter, List(Output))
Feed one input, get the state and everything the caller should do. Output
order: what the input caused, then permits oldest first, then one Wake.
pub fn wake_after(
limiter: Limiter,
now_ms now_ms: Int,
) -> option.Option(Int)
When the limiter next does something useful, in ms from now_ms. None
means idle, so there is nothing to schedule.