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

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.

  • Settled(ticket: Ticket, outcome: Outcome)
  • 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.

  • Tick

    Re-evaluate. Idempotent and always safe.

pub opaque type Limiter
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.

  • Opaque

    Anything else, including transport failure. Frees the bucket and teaches the limiter nothing.

pub type Output {
  Send(ticket: Ticket)
  Refuse(ticket: Ticket, why: Refusal)
  Wake(in_ms: Int)
  Note(notice: Notice)
}

Constructors

  • Send(ticket: Ticket)
  • Refuse(ticket: Ticket, why: Refusal)

    Do not send. The caller fails this ticket.

  • Wake(in_ms: Int)

    Call step(_, Tick) again in about this long. A latency optimisation, not a liveness requirement.

  • Note(notice: Notice)
pub type Refusal {
  InvalidBudgetSpent
  Backlogged
  DuplicateTicket
}

Constructors

  • InvalidBudgetSpent

    The invalid-request budget is spent. Sending would risk a Cloudflare ban on the whole IP, which takes down every bot sharing it.

  • Backlogged

    More than max_pending calls already waiting.

  • DuplicateTicket

    This 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
  • SharedScope

    Somebody else’s traffic on a resource we share. Discord does not count these against the invalid-request budget, and neither do we.

  • GlobalScope

Caller-minted and transparent: the caller has to correlate a permit with its own work anyway, so it owns the id.

pub type Ticket {
  Ticket(Int)
}

Constructors

  • Ticket(Int)

Values

pub fn defaults() -> Limits
pub fn new(limits: Limits) -> Limiter
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.

Search Document