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) -
NoBodyAn 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.
-
OpaqueAnything 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.