glyde/rest/headers

Reading Discord’s rate-limit headers, purely.

Deadlines come from the relative x-ratelimit-reset-after, never the absolute x-ratelimit-reset, which is wrong by our clock’s drift.

Every counter is an Option: a header that did not arrive has to stay distinguishable from one that arrived as zero.

Types

What the 429’s body added. A body in Discord’s rate-limit shape proves Discord answered, which rules out a block in front of it.

pub type BodyEvidence {
  RateLimitBody(global: Bool)
  NoBody
}

Constructors

  • RateLimitBody(global: Bool)
  • NoBody

    An HTML block page, an empty body, or a caller holding only headers.

What one response taught us.

pub type Outcome {
  Learned(limiter.Counters)
  Throttled(
    retry_after_ms: Int,
    scope: limiter.Scope,
    bucket: option.Option(String),
  )
  Rejected(why: RejectedWhy)
  Opaque
}

Constructors

  • Learned(limiter.Counters)

    A response carrying the usual x-ratelimit-* headers.

  • Throttled(
      retry_after_ms: Int,
      scope: limiter.Scope,
      bucket: option.Option(String),
    )
  • Rejected(why: RejectedWhy)

    A refusal that spends the invalid-request budget. Named and not a status, so no other status can be counted against it.

  • Opaque

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

What one response said about a bucket, in the limiter’s own shape so the crossing is a hand-over and not a copy. Build it as limiter.Counters.

pub type RateLimit =
  limiter.Counters

The two statuses Discord counts against the invalid-request budget: 401 and 403.

pub type RejectedWhy {
  Unauthorized
  Forbidden
}

Constructors

  • Unauthorized
  • Forbidden

Whose fault the 429 was, resolved. Only resolve_scope answers this, and glyde/rest/error asks it through the same function, so the two cannot disagree about a response. The limiter’s own type, so handing a reading over costs nothing.

pub type Scope =
  limiter.Scope

What x-ratelimit-scope said, before anything is made of it. Kept apart from Scope so an unresolved reading cannot be passed off as the answer.

pub type ScopeHeader {
  Named(limiter.Scope)
  Unnamed(String)
  Missing
}

Constructors

  • Named(limiter.Scope)
  • Unnamed(String)

    A value Discord has never documented, kept as sent. It names no rule we know, so on its own it decides nothing.

  • Missing

Values

pub const fallback_retry_ms: Int

Our own number, for a 429 carrying neither the retry-after header nor the body field.

pub fn header(
  headers: List(#(String, String)),
  name: String,
) -> option.Option(String)

One header by name, None when it did not arrive. Adapters hand names over in any case, so name must be lowercase.

pub fn outcome(
  status status: Int,
  headers headers: List(#(String, String)),
  body body: String,
) -> Outcome

Classify one response. Total: anything unparseable degrades to “learned nothing”. body is read only on a 429; pass "" for the rest.

pub fn rejected_status(why: RejectedWhy) -> Int

The status Discord sent. The limiter only counts the refusal, so this is for a host reading the outcome back.

pub fn resolve_scope(
  headers: List(#(String, String)),
  body: BodyEvidence,
) -> limiter.Scope

Whose fault a 429 was, from everything the response says. The one classifier: glyde/rest/error resolves the same 429 through this, so no response can be a route limit to one reader and a global limit to the other.

pub fn scope_header(
  headers: List(#(String, String)),
) -> ScopeHeader

What x-ratelimit-scope said, unresolved. resolve_scope is what turns it into an answer.

pub fn seconds_to_ms(raw: String) -> Result(Int, Nil)

Seconds to milliseconds, rounding up, because waking early costs a 429. Read as decimal text, so "0.1" is exactly 100 ms. A negative value is 0.

pub fn to_limiter_outcome(outcome: Outcome) -> limiter.Outcome

What limiter.Settled takes. The limiter is a pure state machine with no idea what a header is, so the crossing lives here, on the wire side.

Search Document