GhEx.RateLimit (gh_ex v0.3.3)

Copy Markdown View Source

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

t()

@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

delay_until_reset(snapshot, opts \\ [])

@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)
end

Options

  • :floor - wait when remaining <= floor. Defaults to 0 (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 to 0.
  • :now - the DateTime to measure from. Defaults to DateTime.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.

from_response(resp)

@spec from_response(Req.Response.t()) :: t() | nil

Builds a snapshot from a response, or nil when no rate-limit headers exist.

get(client)

@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.

retry(request, resp)

@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.