Rate-limit headers and an opt-in retry for GitHub's rate limits.
from_response/1 parses the x-ratelimit-* headers into a snapshot.
retry/2 is a Req-compatible retry policy. Req already retries ordinary
transient errors (5xx, network errors) on its own; this adds awareness of
GitHub's secondary rate limits, which arrive as a 403 (not retried by
default) carrying a retry-after header or x-ratelimit-remaining: 0. Enable
it through :req_options:
GhEx.new(
auth: {:token, token},
req_options: [retry: &GhEx.RateLimit.retry/2]
)Req bounds the attempts with :max_retries (default 3).
Summary
Functions
Computes how long to wait, in milliseconds, before the next request, given the
rate-limit snapshot from a prior response's meta.
Builds a snapshot from a response, or nil when no rate-limit headers exist.
Fetches rate-limit status from GET /rate_limit.
A Req-compatible retry policy that understands GitHub's rate limits.
Types
@type t() :: %GhEx.RateLimit{ limit: non_neg_integer() | nil, remaining: non_neg_integer() | nil, reset: DateTime.t() | nil, resource: String.t() | nil, used: non_neg_integer() | nil }
Functions
@spec delay_until_reset( t() | nil, keyword() ) :: non_neg_integer()
Computes how long to wait, in milliseconds, before the next request, given the
rate-limit snapshot from a prior response's meta.
Returns 0 when there is headroom (remaining above :floor), when the
snapshot is nil, or when remaining/reset are absent. Otherwise returns the
milliseconds until reset (clamped at 0 for an already-past reset), plus an
optional :buffer_ms.
This is a pure calculation; it never sleeps. The library deliberately leaves the
wait to you, so you decide when and whether to block. Feed it the prior
response's meta.rate_limit and act on the result:
{:ok, _body, meta} = GhEx.REST.get(client, path)
case GhEx.RateLimit.delay_until_reset(meta.rate_limit, floor: 50) do
0 -> :ok
ms -> Process.sleep(ms)
endOptions
:floor- wait whenremaining <= floor. Defaults to0(wait only once the bucket is fully exhausted). A small positive floor (e.g.50) pauses while there is still headroom to spare.:buffer_ms- added to a non-zero wait, to absorb clock skew. Defaults to0.:now- theDateTimeto measure from. Defaults toDateTime.utc_now/0; injectable in tests.
Scope
This guards the next call from a snapshot you thread in. It cannot pre-empt the
first call of a session (there is no prior snapshot), and a single snapshot is a
partial view when several processes share one token. retry/2 remains the
reactive backstop for calls that still hit the limit.
@spec from_response(Req.Response.t()) :: t() | nil
Builds a snapshot from a response, or nil when no rate-limit headers exist.
@spec get(GhEx.Client.t()) :: GhEx.REST.result()
Fetches rate-limit status from GET /rate_limit.
This endpoint does not count against your rate limit. Returns the full body,
whose "resources" map covers core, search, graphql, and others.
@spec retry(Req.Request.t(), Req.Response.t() | Exception.t()) :: {:delay, non_neg_integer()} | boolean()
A Req-compatible retry policy that understands GitHub's rate limits.
Returns {:delay, ms} for a 403 or 429 that signals a rate limit, waiting
the retry-after header or, when x-ratelimit-remaining is 0, until
x-ratelimit-reset. GitHub's secondary rate limit can arrive as a 403 whose
only signal is the body message "You have exceeded a secondary rate limit"; in
that case (no retry-after, x-ratelimit-remaining not 0) the body is
inspected and a bounded {:delay, 60000} is returned, the
minimum GitHub recommends. Other 429s and the usual transient statuses
(408, 500, 502, 503, 504) return true so Req applies its own
backoff. A plain 403 (an authorization failure, not a rate limit) is not
retried. Wire it in through :req_options; Req applies :max_retries.