githubratelimit

package
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package githubratelimit handles GitHub's THREE rate-limit mechanisms — primary quota, secondary (abuse-detection) limits, and the GraphQL point budget — as three separate things, because they are three separate things.

Why the package is named after one vendor

Every other connector-facing package in this module is deliberately vendor-neutral: an ABC derived from a single backend encodes that backend's accidents as contract. This package is the opposite on purpose. The three mechanisms below are not a general model of rate limiting that GitHub happens to implement — they are GitHub's, down to the header names and the units. Naming the package after the vendor says so, so that nobody reaches for it as "the rate limiter" and quietly inherits GitHub's shape for a backend that does not have it.

It lives in the SDK rather than in a host because a host reaches GitHub through more than one surface — a tracker and a CI/PR surface at least — and each one needs the identical discipline. Two implementations of this are two implementations that drift, and the drift is invisible until one of them under-waits in production.

The three mechanisms

MechanismPrimary is the hourly quota. It is reported on EVERY response, in the x-ratelimit-remaining / x-ratelimit-reset headers, whether or not the request was refused. That is what makes it the one mechanism a caller can respect BEFORE exhausting it: see Gate.Admit.

MechanismSecondary is abuse detection — burst rate and concurrency, not volume. It is signalled differently (a retry-after header, or a 403/429 whose body names a secondary limit) and, critically, waiting out the PRIMARY reset does not clear it. A handler that reads a secondary refusal as a primary one waits for the wrong thing and is refused again.

MechanismGraphQLCost is a per-query POINT budget, reported in the response BODY's rateLimit field and not in headers at all. A single GraphQL request can spend hundreds of points, so the REST request count says nothing useful about it. See PointBudget.

Collapsing the three into one handler is the failure this package exists to prevent, so the vocabulary is closed (Mechanisms) and Classify reports exactly which one refused a response.

What it guarantees, and what it cannot

For ONE request, Do guarantees completes-after-waiting or a typed failure: it never hands back a value alongside a rate-limit error, so a truncated answer cannot be mistaken for a complete one.

For a MULTI-STEP sequence — a paginated sweep, a batch of board mutations — the guarantee is completes-after-waiting, and it is bought by not letting the sequence START until the whole of it fits in the remaining budget: Gate.Admit takes the number of requests the sequence will make. A sequence that cannot fit waits for the reset; a sequence larger than the entire hourly quota is refused as a caller error rather than waited on forever.

The gate cannot roll back a mutation that already happened. Admitting the whole sequence up front is the mechanism that keeps there from being one.

Waits are visible

Every wait is announced through Options.Notify before it begins, carrying the mechanism, the attempt number, the delay and the wall-clock time it ends (Wait). A sweep that pauses for eleven minutes and says nothing is indistinguishable from a hung one, and an operator kills the second.

Time is injected

Clock is the only source of now and of sleeping. Tests supply a fake and assert the COMPUTED backoff and the NUMBER of attempts; nothing in this package's tests measures elapsed wall-clock time, which is how a backoff test flakes under -race.

Index

Examples

Constants

View Source
const (
	// DefaultAttempts is the total number of attempts, including the first —
	// not the number of retries.
	DefaultAttempts = 5
	// DefaultBase is the first backoff delay, doubling per attempt.
	DefaultBase = 1 * time.Second
	// DefaultMaxDelay caps ONE backoff delay. It does not cap a wait the
	// mechanism itself dictated: a primary reset an hour out is an hour, and
	// truncating it to a minute would retry into the limit 59 times.
	DefaultMaxDelay = 2 * time.Minute
	// DefaultJitter is the fraction of each computed backoff that is
	// randomised, so a delay lands uniformly in [d/2, d].
	//
	// Jitter is not a refinement. Fixed backoff makes every worker that was
	// refused at the same instant retry at the same instant, which is the
	// burst pattern the secondary limit refused them for — the herd is worse
	// than the original limit, and it re-forms on every retry.
	DefaultJitter = 0.5
	// MinBackoff is the shortest delay [Gate.Backoff] will return.
	//
	// It exists because full jitter ([Options.Jitter] of 1) has a window floor
	// of ZERO by definition, so an unlucky draw computes a delay of nothing —
	// and a "backoff" of nothing is a tight retry straight back into the limit
	// that refused the request, which is the one outcome this package must not
	// produce. The floor is small enough to be irrelevant against any real
	// GitHub reset and large enough to still be a pause.
	MinBackoff = time.Millisecond
)

Defaults for the Options a caller leaves zero. They are chosen for a host making bursts of board reads and writes against one repository, which is the traffic shape that provokes GitHub's secondary limits.

View Source
const (
	HeaderLimit     = "X-Ratelimit-Limit"
	HeaderRemaining = "X-Ratelimit-Remaining"
	HeaderReset     = "X-Ratelimit-Reset"
	HeaderUsed      = "X-Ratelimit-Used"
	// HeaderResource names WHICH quota bucket the other four describe —
	// "core", "search", "graphql", and others. It matters because the buckets
	// are independent and small ones exhaust fast: search is 30 requests a
	// minute against core's 5000 an hour. A handler that folds them into one
	// number stalls all core work for an hour the first time a search is
	// refused. See [Options.Resource].
	HeaderResource = "X-Ratelimit-Resource"
	// HeaderRetryAfter is the SECONDARY mechanism's signal, not the primary
	// one. It is named here because [ParsePrimary] deliberately does not read
	// it — see [Classify].
	HeaderRetryAfter = "Retry-After"
)

The primary-quota headers GitHub puts on every response, refused or not.

They are spelled in net/http's CANONICAL form — "X-Ratelimit-Remaining", with a lower-case "l", not the "X-RateLimit-Remaining" GitHub's own documentation uses. That is not a typo, and getting it wrong is a silent failure rather than a loud one.

http.Header is a map whose keys are canonicalised by Get and Set, so both spellings behave identically through those methods and only ONE works when a consumer indexes or builds the map directly:

h := http.Header{githubratelimit.HeaderRemaining: {"0"}, ...}  // works
h["X-RateLimit-Remaining"] = []string{"0"}                     // silently invisible to Get

An earlier revision of this package's own tests built fixture headers the second way and passed while asserting nothing — which is exactly the reason the canonical spelling ships here rather than the documentation's.

View Source
const GraphQLRateLimitedErrorType = "RATE_LIMITED"

GraphQLRateLimitedErrorType is the value GitHub puts in a GraphQL error's `type` field when the POINT budget refused the query.

It is a string constant rather than a substring match on the message because the message is prose the vendor may reword, and this package's whole premise is that classification does not depend on prose — the same reason the cerr.Kind vocabulary exists.

View Source
const MaxRetryAfter = 24 * time.Hour

MaxRetryAfter is the longest wait a retry-after header is believed for.

GitHub's primary window is an hour and its secondary limits clear in minutes, so nothing longer than a day is a rate limit GitHub has. The bound exists because the alternative is not merely an absurd wait but a WRONG one: time.Duration counts nanoseconds in an int64, so `retry-after: 9300000000` multiplied by time.Second overflows and comes back NEGATIVE — and a negative wait is silently discarded, throwing away the very signal the header carried. A value just under the overflow point is worse still: it is a positive, believable-looking 146 years.

Clamping DOWN rather than rejecting the header is the safe direction. A rejected header would stop being a secondary marker at all, and the refusal could then classify as nothing — a call returned as a bare 403 with no mechanism named. Clamped, the mechanism is still reported and the wait is merely capped, which a context deadline would bound anyway.

View Source
const ResourceCore = "core"

ResourceCore is the bucket GitHub bills ordinary REST calls against, and the bucket a Gate assumes when nothing else is said.

Variables

This section is empty.

Functions

func Do

func Do[T any](ctx context.Context, g *Gate, op string, fn func(context.Context) (T, Observation, error)) (T, error)

Do runs fn under the gate's rate-limit discipline and returns fn's value only when no rate limit refused it.

fn returns three things: its value, an Observation of the response, and its error. The value and the observation are separate because a refused attempt has an observation and no usable value — which is the case this signature exists to make unrepresentable.

Fail closed

When the verdict is a rate-limit refusal, fn's value is DISCARDED and the zero T is returned. That holds even when fn returned a nil error, which is not a hypothetical: GitHub refuses a point-exhausted GraphQL query with HTTP 200 and a partial or null `data`, so a caller decoding that body gets a value, no error, and half an answer. A truncated answer that is indistinguishable from a complete one is the defect this discards it to prevent. On exhausting the attempts, Do returns the zero T and an Error naming the mechanism, the attempt count and the reset time.

What it retries, and what it does not

Rate-limit refusals only. Any other failure — a 500, a 404, a 403 for a missing scope, a transport error — is returned verbatim on the first attempt, with fn's own value passed straight back if fn reported none. A wrapper that also retried those would turn a broken backend into a slow one and a missing permission into a five-attempt stall.

The wait per attempt

The refusal's own Verdict.RetryAfter when it named one; otherwise a jittered Gate.Backoff. A secondary limit frequently names none, which is exactly when the jitter matters: every worker refused in the same burst would otherwise retry in the same instant and re-form the burst.

Before each attempt it calls Gate.Admit for one request, so a known-spent primary budget is waited out rather than spent into a refusal. It does not call Gate.AdmitPoints: the cost of a GraphQL query is the caller's estimate to make, and guessing one here would be a number nobody could justify.

Example

ExampleDo shows the fail-closed behaviour that matters most.

GitHub refuses a point-exhausted GraphQL query with HTTP **200** and PARTIAL data, so the attempt below returns a value AND a nil error while holding half an answer. Do discards it: a truncated result indistinguishable from a complete one is the whole defect.

The clock is faked so the example's output is deterministic and it does not actually wait; production leaves Options.Clock nil for the real one, and leaves Options.Jitter unset so the backoff is jittered.

gate := githubratelimit.New(githubratelimit.Options{
	Clock:    newFakeClock(),
	Attempts: 2,
	Jitter:   -1, // deterministic here only — never in production
	Notify: func(w githubratelimit.Wait) {
		fmt.Printf("waiting %s on the github %s limit (attempt %d of %d, dictated=%t)\n",
			w.Delay, w.Mechanism, w.Attempt, w.Attempts, w.Dictated)
	},
})

body := []byte(`{"data":{"repository":{"pullRequests":{"nodes":[{"number":1}]}}},` +
	`"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`)

prs, err := githubratelimit.Do(context.Background(), gate, "ListPRs",
	func(context.Context) ([]int, githubratelimit.Observation, error) {
		// The dangerous return a real decoder would produce here.
		return []int{1}, githubratelimit.Observation{Status: http.StatusOK, Body: body}, nil
	})

fmt.Printf("prs=%v (the partial answer never escapes)\n", prs)
fmt.Println("classified as:", cerr.KindOf(err))
fmt.Println("opens the host breaker:", cerr.TripsBreaker(err))
Output:
waiting 1s on the github graphql_cost limit (attempt 1 of 2, dictated=false)
prs=[] (the partial answer never escapes)
classified as: rate_limited
opens the host breaker: true

func RefusedForPoints

func RefusedForPoints(body []byte) bool

RefusedForPoints reports whether a GraphQL response body carries the point budget's own refusal: an entry in `errors` whose type is GraphQLRateLimitedErrorType.

This is the detection path a REST-shaped handler does not have. GitHub answers a point-exhausted GraphQL query with HTTP **200** and an error in the body, so status-code inspection reports success and the caller reads a response whose `data` is null or partial. That is the truncation this package is here to make impossible to miss — Classify reports it as MechanismGraphQLCost regardless of the status code.

Types

type Clock

type Clock interface {
	// Now reports the current time.
	Now() time.Time
	// Sleep blocks for d, or until ctx is done, whichever happens first. It
	// returns ctx.Err() in the second case and nil in the first, so a caller
	// can tell a completed wait from an abandoned one — a distinction a bare
	// time.Sleep cannot make, and the reason this is not just a Now() seam.
	//
	// A non-positive d returns immediately, and returns nil even if ctx is
	// already done: there was nothing to interrupt.
	Sleep(ctx context.Context, d time.Duration) error
}

A Clock is the ONLY source of now and of waiting in this package.

It exists so that the tests for a package whose entire job is waiting do not wait. A backoff test that measures elapsed wall-clock time asserts a property of the machine it runs on — and under a -race build with coverage instrumentation, that machine is several times slower than the one the threshold was chosen on. Those tests do not fail honestly; they flake. With the clock injected, the tests assert the COMPUTED delay and the NUMBER of attempts, both of which are exact.

A Clock MUST be safe for concurrent use: a Gate is, and it calls straight through.

type Error

type Error struct {
	// Op is the operation the caller named, e.g. "ListProjectItems".
	Op string
	// Mechanism is which limit refused. It is never [MechanismNone]: an Error
	// is only built from a refusal.
	Mechanism Mechanism
	// Attempts is how many attempts were made in total, including the first.
	Attempts int
	// RetryAt is when the mechanism is expected to clear, in the [Clock]'s
	// time. It is the zero Time when the mechanism named no reset — a
	// secondary limit with no retry-after does not say when it ends, and
	// inventing a time would be worse than admitting that.
	RetryAt time.Time
	// Err is the underlying cause, where there is one distinct from the
	// refusal itself (a context cancellation during the wait, say).
	Err error
}

An Error reports that a rate limit was not cleared within the attempts allowed, naming WHICH mechanism refused and WHEN it is expected to clear.

It is a distinct type rather than a bare error for the reason the whole package exists: "rate limited" alone tells an operator nothing actionable, while "secondary, 3 attempts, clears at 14:32Z" tells them whether to wait or to reduce concurrency — two opposite remedies for two mechanisms that look identical from the outside.

Its Unwrap exposes cerr.KindRateLimited, so host code that only classifies — cerr.KindOf, cerr.TripsBreaker — gets the right answers without knowing this type exists. In particular TripsBreaker is true: the backend itself reported the refusal, which is exactly the positive evidence a breaker is meant to open on.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap exposes the refusal to the cerr taxonomy AND keeps the underlying cause reachable, so errors.Is against a context error still works through a wait that was cancelled.

type Gate

type Gate struct {
	// contains filtered or unexported fields
}

A Gate applies GitHub's three rate limits to a stream of requests: it holds the last-known budgets, waits BEFORE spending a budget it knows is spent, and waits AFTER a refusal for as long as the refusing mechanism says.

It is safe for concurrent use by multiple goroutines, which is not optional: the traffic shape that provokes GitHub's secondary limits is a burst of parallel workers, so a gate only usable from one goroutine would be a gate for the case that does not need one.

Build it with New. A Gate that was NOT built with New — `var g Gate`, or one embedded in another struct — still works: every method defaults it on first use, exactly as New would have. See [Gate.ensure].

func New

func New(opts Options) *Gate

New builds a Gate from opts, filling every unset field with its documented default. It never returns nil.

func (*Gate) Admit

func (g *Gate) Admit(ctx context.Context, requests int) error

Admit blocks until the recorded primary budget has room for requests more calls beyond Options.Reserve, and is the pre-emptive half of this package.

This is the answer to "never half-applied"

A sequence of mutations that runs out of budget halfway through leaves the remote half-changed, and no amount of retrying afterwards can tell an operator which half ran. The guarantee this gate makes is COMPLETES-AFTER-WAITING, and the way it buys that guarantee is to refuse to let the sequence START until the whole of it fits: a caller passes the number of requests the sequence will make, once, before the first one.

The gate cannot roll back a mutation that already happened, so it does not pretend to offer the other guarantee. Admitting the whole sequence up front is the mechanism that keeps there from being a mutation to roll back.

What it does in each state

  • requests <= 0: returns immediately. Nothing was asked for.
  • the budget is UNKNOWN: admits. There is no evidence of exhaustion, and pre-emptively blocking on no evidence would let one missing header stall a host forever. The request itself is the probe that measures the budget.
  • the budget has room: admits.
  • the sequence is larger than the whole bucket minus the reserve: returns cerr.KindInvalid WITHOUT waiting. No amount of waiting makes it fit, and a wait that can never succeed is a hang with extra steps.
  • the budget is short and the window has not rolled over: announces a Wait and sleeps until the reset, then forgets the spent budget so the next response re-measures it.
  • the budget is short and the window has already passed: admits, and forgets the spent budget. The recorded numbers describe a window that is over; the next response carries the new ones.

A ctx that is cancelled or times out during the wait ends it, and Admit returns an Error naming MechanismPrimary and the reset time — so a host with a deadline gets a typed refusal that says when the limit clears, not a bare context error and not a partially-run sequence. A host that must not block for the remainder of an hour bounds the wait that way: the context IS the ceiling.

Example

ExampleGate_Admit shows the never-half-applied guarantee.

The budget has room for 12 of the 40 mutations. Admit is called ONCE, before the first one, with the size of the whole sequence — so the wait happens with nothing yet applied, and the sequence then runs to completion. The gate cannot roll back a mutation that already happened; admitting the whole sequence up front is what keeps there from being one.

const mutations = 40

gate := githubratelimit.New(githubratelimit.Options{
	Clock: newFakeClock(),
	// Room kept in hand for the calls a host must still make when the bulk
	// work stops — a final status read, an error report.
	Reserve: 50,
	Notify: func(w githubratelimit.Wait) {
		fmt.Printf("holding the sequence %s on the github %s limit, until %s\n",
			w.Delay, w.Mechanism, w.Until.Format(time.RFC3339))
	},
})

// What the last response reported: 12 requests left in the hourly quota.
gate.Observe(githubratelimit.Observation{
	Status: http.StatusOK,
	Header: primaryHeader(12, 5000, 4988, resetUnix, githubratelimit.ResourceCore),
	Body:   []byte(`{}`),
})

applied := 0
if err := gate.Admit(context.Background(), mutations); err != nil {
	fmt.Println("refused:", err)
	return
}
for range mutations {
	applied++
}
fmt.Printf("applied %d of %d, and none of them ran before the wait\n", applied, mutations)
Output:
holding the sequence 15m0s on the github primary limit, until 2026-07-30T15:00:00Z
applied 40 of 40, and none of them ran before the wait

func (*Gate) AdmitPoints

func (g *Gate) AdmitPoints(ctx context.Context, points int) error

AdmitPoints is Gate.Admit for the GraphQL point budget, which is accounted separately because it is a separate budget: a caller with thousands of REST requests remaining can have no points at all.

It behaves identically state for state, against the recorded PointBudget and Options.PointReserve, and reports MechanismGraphQLCost on the wait and on any failure. A GraphQL sweep that will also spend request count calls both.

func (*Gate) Attempts

func (g *Gate) Attempts() int

Attempts reports the total attempts Do will make, including the first.

func (*Gate) Backoff

func (g *Gate) Backoff(attempt int) time.Duration

Backoff reports the delay before the attempt after the given 1-based attempt: Backoff(1) is the pause after the first attempt was refused.

It is exponential from Options.Base, doubling per attempt, capped at Options.MaxDelay, and then jittered — the delay lands uniformly in [(1-jitter)·d, d]. Jitter is applied AFTER the cap so that the cap is a ceiling on what is actually slept rather than on the pre-jitter figure.

It is exported because it is the thing a test should assert. Asserting the computed delay is exact; asserting that a sleep took roughly that long is a measurement of the machine, and under a -race build with coverage instrumentation that machine is slow enough to fail a threshold chosen on an idle one.

The returned delay is NEVER zero or negative: it is at least MinBackoff, unless Options.MaxDelay is itself smaller, in which case MaxDelay wins because a caller's explicit ceiling outranks this package's floor. An attempt below 1 is treated as 1, and an attempt large enough to overflow the doubling returns the cap, jittered, rather than a negative duration.

func (*Gate) Observe

func (g *Gate) Observe(obs Observation) Verdict

Observe classifies a response and records the budgets it reported, returning the Verdict so the caller can act on it directly.

A primary budget for a DIFFERENT bucket than this gate's is returned in the Verdict but NOT recorded — see Options.Resource. It is returned rather than dropped so that a host wiring one gate where it needs two can see the mismatch instead of watching pre-emption quietly never fire.

Recording happens whether or not the response was a refusal. That is the whole basis of respecting the primary limit before exhaustion rather than after: the budget arrives on every response, including the successful ones.

A late response cannot resurrect a spent budget

Within one window GitHub's remaining only ever goes DOWN, but responses to concurrent requests do not arrive in the order they were sent — so a reply to an earlier request can land after one reporting a lower figure, and recording it verbatim would raise the recorded remaining back up. That is a fail-open: the gate would then wave the next request straight through into a refusal it already had the evidence to avoid.

So for the SAME window the lower remaining wins, and a budget describing an OLDER window is ignored entirely. A budget for a later window replaces whatever is held, because the window really has rolled over.

func (*Gate) Points

func (g *Gate) Points() PointBudget

Points reports the last GraphQL point budget the gate recorded, UNKNOWN until a response carrying one has been observed.

func (*Gate) Primary

func (g *Gate) Primary() PrimaryBudget

Primary reports the last primary budget the gate recorded, which is the UNKNOWN budget until a response has been observed for this gate's bucket.

func (*Gate) Resource

func (g *Gate) Resource() string

Resource reports which primary quota bucket this gate tracks.

type Mechanism

type Mechanism int

A Mechanism names WHICH of GitHub's rate limits refused a request.

The set is closed for the same reason cerr.Kinds is: a caller acts on the name, and the whole point of the package is that the three get different responses. A mechanism outside the set is not a classification — see Mechanism.Valid.

const (
	// MechanismNone is the zero value: nothing in the response said a rate
	// limit refused it. It is NOT "the budget is healthy" — a response can
	// carry no rate-limit signal at all, and [PrimaryBudget.Known] is what
	// distinguishes those two.
	MechanismNone Mechanism = iota
	// MechanismPrimary is the hourly quota, signalled by
	// x-ratelimit-remaining reaching zero. The wait is until
	// x-ratelimit-reset; retrying before then is refused again.
	MechanismPrimary
	// MechanismSecondary is abuse detection: too many requests too fast, or
	// too many concurrent. Signalled by a retry-after header, or by a 403/429
	// whose body names a secondary limit.
	//
	// It is the mechanism a naive handler gets wrong, because the primary
	// budget on the very same response can read as healthy — remaining is not
	// zero, so "wait for the reset" computes a wait of nothing and retries
	// straight back into the limit.
	MechanismSecondary
	// MechanismGraphQLCost is the GraphQL point budget, reported in the
	// response body's rateLimit field rather than in any header. It is
	// accounted separately from the REST request count because one request can
	// cost hundreds of points — a caller with thousands of REST requests left
	// can have no points at all.
	MechanismGraphQLCost
)

func Mechanisms

func Mechanisms() []Mechanism

Mechanisms returns the closed mechanism vocabulary, in ascending order, as a copy. A caller cannot narrow or extend it by mutating the result.

func (Mechanism) Limited

func (m Mechanism) Limited() bool

Limited reports whether m names an actual refusal — every mechanism except MechanismNone. It exists so a caller writes one predicate instead of enumerating three constants and forgetting the one added next.

func (Mechanism) String

func (m Mechanism) String() string

String renders m's stable log name. A value outside the vocabulary renders as invalid_mechanism(N) rather than as "none", so it cannot hide behind the zero value in a message.

func (Mechanism) Valid

func (m Mechanism) Valid() bool

Valid reports whether m is in the closed vocabulary.

type Observation

type Observation struct {
	// Status is the HTTP status code. Zero means the caller did not record one
	// — a transport that never got a response, say — and no header-based
	// reading fires.
	Status int
	// Header is the response's headers. It may be nil.
	Header http.Header
	// Body is the response body, already read by the caller. It may be nil;
	// the GraphQL signals are the only ones that need it.
	Body []byte
	// Points is a point budget the caller ALREADY decoded from a typed GraphQL
	// result — see [NewPointBudget]. When it is known, [Classify] uses it and
	// does not re-parse Body for a budget. When it is unknown, Classify falls
	// back to [ParsePointBudget] over Body.
	Points PointBudget
}

An Observation is everything one attempt saw that could carry a rate-limit signal. A caller fills in whichever fields its transport actually exposes; Classify reads what is there and reports what it could not determine as unknown rather than as healthy.

func FromHTTP

func FromHTTP(resp *http.Response, body []byte) Observation

FromHTTP builds an Observation from a response and the body a caller has already read off it.

It takes the body separately rather than reading resp.Body itself, because a response body can be read once: a helper that consumed it would leave the caller unable to decode its own payload, and one that buffered and replaced it would silently change the caller's memory profile on every request.

A nil resp yields the zero Observation, in which nothing is known.

type Options

type Options struct {
	// Clock is the time seam. Nil means [SystemClock].
	Clock Clock
	// Resource names the primary quota bucket this gate tracks — see
	// [HeaderResource]. Empty means [ResourceCore].
	//
	// One gate tracks ONE bucket. The buckets are independent and wildly
	// different sizes (search is 30 a minute against core's 5000 an hour), so a
	// gate that recorded them into one number would stall every core request
	// for the rest of the hour the first time a search was refused. A host
	// reaching more than one bucket builds more than one gate.
	Resource string
	// Reserve is how many primary requests [Gate.Admit] keeps in hand. It buys
	// room for the calls a host must still make when the bulk work stops — a
	// final status read, an error report — which are the calls an operator
	// needs most at exactly the moment the budget ran out.
	Reserve int
	// PointReserve is the same idea for the GraphQL point budget.
	PointReserve int
	// Attempts is the total attempts [Do] makes, including the first. Zero or
	// negative means [DefaultAttempts]. One means no retry at all, which is a
	// legitimate configuration: the caller is asking to be told about the
	// limit rather than to have it waited out.
	Attempts int
	// Base is the first backoff delay. Zero or negative means [DefaultBase].
	Base time.Duration
	// MaxDelay caps one computed backoff. Zero or negative means
	// [DefaultMaxDelay].
	MaxDelay time.Duration
	// Jitter is the randomised fraction of each computed backoff, 0..1. Zero
	// means [DefaultJitter]; to switch jitter OFF — which no production
	// configuration should — pass a negative value, so that turning off the
	// defence against a thundering herd has to be deliberate rather than the
	// consequence of leaving a field unset.
	Jitter float64
	// Rand returns a value in [0,1). Nil means math/rand/v2. The gate calls it
	// under its own lock, so an injected function need not be safe for
	// concurrent use — which is what lets a test supply a plain counter and
	// assert an exact delay.
	Rand func() float64
	// Notify receives a [Wait] before each pause. Nil means waits are silent,
	// which is a supported but poor choice — see [Wait].
	Notify func(Wait)
}

Options configures a Gate. Every field has a working default, so Options{} is usable.

type PointBudget

type PointBudget struct {
	// Limit is the point budget for this window.
	Limit int
	// QueryCost is what the query that reported this budget cost — GraphQL's
	// `cost` field. It is the query's own price, not a running total; Used is
	// the running total.
	QueryCost int
	// Remaining is how many points are left in the window.
	Remaining int
	// Used is how many have been spent.
	Used int
	// NodeCount is how many nodes the reporting query returned. GitHub caps it
	// independently of points, so a query can be refused for node count with
	// points to spare — recorded here so a caller can see it, not acted on by
	// this package.
	NodeCount int
	// ResetAt is when the point window rolls over.
	ResetAt time.Time
	// contains filtered or unexported fields
}

A PointBudget is the GraphQL point budget as one response reported it.

GraphQL is not billed per request. A query's cost is computed from the nodes it asks for, so one request can spend hundreds of points while the REST request count barely moves — which is why this is accounted separately from PrimaryBudget rather than being folded into it. A caller with 4900 REST requests remaining can have zero points, and a handler that reads only x-ratelimit-remaining sees nothing wrong right up until the query is refused.

Two things about GitHub's GraphQL limit surprise a handler written against the REST one, and both are why this needs its own detection path:

  • it is reported in the response BODY (data.rateLimit), not in any header;
  • its refusal arrives on HTTP **200**, as an entry in the response's `errors` array with type RATE_LIMITED — not as a 403 or a 429.

A handler that only inspects status codes and headers therefore cannot see this limit at all. See RefusedForPoints.

Like PrimaryBudget, the zero value is UNKNOWN rather than empty — see PointBudget.Known.

func NewPointBudget

func NewPointBudget(fields PointBudget) PointBudget

NewPointBudget marks a caller-decoded budget as MEASURED.

It exists because the useful way to read GraphQL is a typed client whose generated struct already holds the rateLimit fields — re-decoding the raw body just to reach them would mean parsing every response twice. A caller fills the exported fields from its own query result and passes the value through here, which is the moment it ASSERTS that these numbers came off a real response.

The assertion is checked, not taken on faith: a budget with no ResetAt comes back UNKNOWN, because a budget that cannot say when it clears cannot be waited on, and the only wait derivable from it is none — a spin straight back into the limit. That is the same refusal ParsePrimary makes on a response carrying remaining without a reset.

func ParsePointBudget

func ParsePointBudget(body []byte) (PointBudget, bool)

ParsePointBudget reads data.rateLimit out of a raw GraphQL response body.

It reports false — and the zero, UNKNOWN budget — for a body that is not JSON, that carries no data.rateLimit (the common case: a query that did not ask for it), or whose rateLimit omits either of the two fields a decision is actually made from: `remaining` and a parseable `resetAt`. Limit, cost, used and nodeCount are recorded when present and left zero when not.

A caller using a typed GraphQL client should prefer NewPointBudget over this: the typed result already holds the fields, and decoding the body a second time to reach them is work for nothing.

func (PointBudget) Exhausted

func (b PointBudget) Exhausted() bool

Exhausted reports POSITIVE evidence that the point budget is spent.

func (PointBudget) Headroom

func (b PointBudget) Headroom(reserve int) int

Headroom reports how many points may be spent while keeping reserve in hand, floored at zero. An unknown budget has no headroom.

func (PointBudget) Known

func (b PointBudget) Known() bool

Known reports whether this budget was actually read from a response.

The same asymmetry PrimaryBudget.Known documents applies here, for the same reasons: an unknown budget is never PointBudget.Exhausted (no evidence of exhaustion, so nothing to wait for) and has no PointBudget.Headroom (no evidence of room, so nothing to admit a sequence against).

func (PointBudget) ResetIn

func (b PointBudget) ResetIn(now time.Time) time.Duration

ResetIn reports how long until the point window rolls over, floored at zero.

type PrimaryBudget

type PrimaryBudget struct {
	// Limit is the bucket's size for this window.
	Limit int
	// Remaining is how many requests are left in it.
	Remaining int
	// Used is how many have been spent, where GitHub reported it.
	Used int
	// Reset is when the window rolls over and Remaining returns to Limit.
	Reset time.Time
	// Resource names the bucket — see [HeaderResource]. It is empty when the
	// response did not say, which older GitHub Enterprise versions do not.
	Resource string
	// contains filtered or unexported fields
}

A PrimaryBudget is the hourly quota as one response reported it.

It is a value, not a pointer, and its zero value is DELIBERATELY not a healthy budget: PrimaryBudget.Known reports false for it. That distinction is the whole reason this is a struct rather than two ints — a response whose headers could not be read must not be indistinguishable from one reporting 5000 remaining, or the pre-emptive check in Gate.Admit silently stops checking anything.

func ParsePrimary

func ParsePrimary(h http.Header) (PrimaryBudget, bool)

ParsePrimary reads the primary-quota headers off a response.

It reports false — and returns the zero, UNKNOWN budget — unless the two headers the quota is actually decided from are both present and parse: x-ratelimit-remaining and x-ratelimit-reset. Limit, Used and Resource are recorded when present and left at their zero values when not, because none of them changes a decision; remaining and reset both do.

Requiring BOTH is the fail-closed reading. A response carrying remaining without a reset would let Gate.Admit conclude "exhausted" with no idea how long to wait, and the only wait it could then compute is none — a spin straight back into the limit. Treating that as "no information" instead sends the caller to the one thing that does resolve it: making the request and reading a complete set of headers off the answer.

It deliberately does NOT read retry-after. That header is the secondary mechanism's, and folding it in here is the exact collapse this package exists to prevent — see Classify.

func (PrimaryBudget) AttributableTo

func (b PrimaryBudget) AttributableTo(resource string) bool

AttributableTo reports whether b may be recorded as the state of the named bucket — either because it says it IS that bucket, or because it does not say which bucket it is at all.

The unlabelled case is admitted deliberately, and it is the one place this package prefers a possible mis-attribution to a certain fail-open. GitHub Enterprise versions that predate x-ratelimit-resource label nothing, so refusing an unlabelled budget would mean a Gate against such a server records no budget ever and pre-emption silently stops working — the exact "green because nothing looked" failure. On a server that DOES label, every budget is labelled, so the ambiguity does not arise there.

func (PrimaryBudget) Exhausted

func (b PrimaryBudget) Exhausted() bool

Exhausted reports POSITIVE evidence that the hourly quota is spent: a known budget with nothing remaining. An unknown budget is never exhausted — see PrimaryBudget.Known.

func (PrimaryBudget) Headroom

func (b PrimaryBudget) Headroom(reserve int) int

Headroom reports how many requests may be spent while keeping reserve in hand, floored at zero. An unknown budget has no headroom, so a caller asking "may I run 40 mutations" against an unmeasured budget is told to find out first rather than being waved through.

func (PrimaryBudget) Known

func (b PrimaryBudget) Known() bool

Known reports whether this budget was read from a response's headers.

An unknown budget is not a full one and not an empty one — it is no information, and every predicate below treats it that way: PrimaryBudget.Exhausted is false (there is no evidence of exhaustion) and PrimaryBudget.Headroom is zero (there is no evidence of room). The asymmetry is intentional. Refusing to pre-emptively wait on no evidence keeps a missing header from stalling a host forever, while refusing to CLAIM room on no evidence keeps a sequence from being admitted against a budget nobody measured.

func (PrimaryBudget) ResetIn

func (b PrimaryBudget) ResetIn(now time.Time) time.Duration

ResetIn reports how long until the window rolls over, as measured from now, floored at zero. A budget with no reset time reports zero: there is nothing to wait for that this budget knows about.

type SystemClock

type SystemClock struct{}

SystemClock is the real clock: wall time and a real, cancellable sleep. It is what New uses when Options.Clock is nil.

It carries no state, so the zero value is usable and copying it is free.

func (SystemClock) Now

func (SystemClock) Now() time.Time

Now reports the wall-clock time.

func (SystemClock) Sleep

func (SystemClock) Sleep(ctx context.Context, d time.Duration) error

Sleep waits out d on a timer, abandoning the wait if ctx is done first. The timer is always stopped, so a cancelled wait does not leave one armed for the remainder of what could be a full hour.

type Verdict

type Verdict struct {
	// Mechanism is which limit refused, or [MechanismNone] if none did.
	//
	// When more than one marker is present it is the most SPECIFIC of them —
	// see [Classify] for the precedence and why it runs that way. Signals
	// records all of them.
	Mechanism Mechanism
	// RetryAfter is how long to wait before retrying, as the refusal itself
	// dictates. It is the LONGEST wait any observed marker called for, so a
	// response carrying two limits satisfies both.
	//
	// It is zero when the mechanism named no wait — a secondary limit with no
	// retry-after header does not say when it ends. A caller MUST NOT read
	// zero as "retry immediately": that is the case where a jittered backoff
	// is required, and [Gate] supplies one.
	RetryAfter time.Duration
	// RetryAt is when the refusal is expected to clear, in the clock's time.
	// Zero exactly when RetryAfter is zero.
	RetryAt time.Time
	// Primary is the hourly quota as this response reported it — known or not,
	// and recorded whether or not the request was refused. This is the field
	// that makes the pre-emptive check possible: see [Gate.Admit].
	Primary PrimaryBudget
	// Points is the GraphQL point budget as this response reported it, known or
	// not.
	Points PointBudget
	// Signals is every mechanism whose marker was present, in ascending order,
	// not only the one Mechanism reports.
	//
	// It exists because the mechanisms genuinely co-occur — a 429 can arrive
	// with the hourly quota also spent — and a caller or a test that only ever
	// sees the winner cannot tell a correctly-prioritised reading from one that
	// never noticed the second signal.
	Signals []Mechanism
}

A Verdict is Classify's reading of one Observation: which mechanism refused the request, how long the refusal itself says to wait, and both budgets as they were reported.

func Classify

func Classify(obs Observation, now time.Time) Verdict

Classify reads one Observation and reports which of GitHub's three mechanisms refused it.

Detection, per mechanism

SECONDARY, in the order checked: a retry-after header (seconds, or an HTTP date) on a refusal status; a 429 status, which GitHub uses for secondary limits and which is a typed signal rather than prose; or a refusal status whose body names a secondary rate limit or an abuse-detection trigger.

GRAPHQL POINT COST: an errors entry of type RATE_LIMITED in the body — see RefusedForPoints — checked WITHOUT regard to the status code, because this refusal arrives on HTTP 200.

PRIMARY: a refusal status whose x-ratelimit-remaining has reached zero. A remaining of zero on a SUCCESSFUL response is not a refusal — it is the request that spent the last unit — and it is reported through Primary, where Gate.Admit acts on it before the next request is ever made.

Precedence, and why it runs this way

Secondary beats everything. It is the only mechanism whose remedy is not derivable from a budget: waiting out the primary reset, or the point reset, does NOT clear a secondary limit. So mis-reading a secondary refusal as one of the other two under-waits and is refused again, while the reverse merely over-waits. Between a wrong answer and a slow one, this package picks slow.

Point cost beats primary. Both may be signalled on one response — a point-refused GraphQL query is billed against the graphql quota bucket, whose headers can read exhausted on the same response — and the operator's remedy differs: make the query cheaper versus make fewer requests. Reporting the specific one is what makes the message actionable.

RetryAfter is not chosen by precedence. It is the LONGEST wait any observed marker dictated, so a response carrying two limits waits long enough for both. That is the same over-wait-rather-than-under-wait choice, applied to the duration instead of the name.

What it does NOT do

It does not classify anything else. A 403 for a missing scope, a 404, a 500, a transport error — all of them are MechanismNone, and a caller must return them as the failures they are. This package retries rate limits, not requests: a general retry wrapper that also swallowed a 500 would turn a broken backend into a slow one.

Example

ExampleClassify shows the three mechanisms being told apart. The second case is the one a naive handler gets wrong: on a secondary refusal the hourly quota reads HEALTHY, so a wait derived from the budget would be no wait at all.

package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/arqtiqa/arqtos-sdk-go/githubratelimit"
)

func main() {
	now := time.Date(2026, 7, 30, 14, 45, 0, 0, time.UTC)
	reset := now.Add(15 * time.Minute)

	quotaSpent := http.Header{}
	quotaSpent.Set(githubratelimit.HeaderRemaining, "0")
	quotaSpent.Set(githubratelimit.HeaderReset, fmt.Sprint(reset.Unix()))

	burst := http.Header{}
	burst.Set(githubratelimit.HeaderRemaining, "4999") // healthy!
	burst.Set(githubratelimit.HeaderReset, fmt.Sprint(reset.Unix()))
	burst.Set(githubratelimit.HeaderRetryAfter, "60")

	for _, obs := range []githubratelimit.Observation{
		{Status: http.StatusForbidden, Header: quotaSpent, Body: []byte(`{"message":"API rate limit exceeded"}`)},
		{Status: http.StatusForbidden, Header: burst, Body: []byte(`{"message":"You have exceeded a secondary rate limit."}`)},
		{Status: http.StatusOK, Body: []byte(`{"data":{"rateLimit":{"remaining":0,"resetAt":"2026-07-30T15:30:00Z"}},` +
			`"errors":[{"type":"RATE_LIMITED","message":"API rate limit exceeded"}]}`)},
	} {
		v := githubratelimit.Classify(obs, now)
		fmt.Printf("http %d -> %-12s wait %-8s (hourly quota spent: %t)\n",
			obs.Status, v.Mechanism, v.RetryAfter, v.Primary.Exhausted())
	}
}
Output:
http 403 -> primary      wait 15m0s    (hourly quota spent: true)
http 403 -> secondary    wait 1m0s     (hourly quota spent: false)
http 200 -> graphql_cost wait 45m0s    (hourly quota spent: false)

func (Verdict) Limited

func (v Verdict) Limited() bool

Limited reports whether the verdict is a rate-limit refusal at all.

type Wait

type Wait struct {
	// Op is the operation the caller named, empty for a pre-emptive
	// [Gate.Admit] wait that belongs to no single call.
	Op string
	// Mechanism is which limit is being waited on.
	Mechanism Mechanism
	// Attempt is the 1-based attempt that was refused.
	Attempt int
	// Attempts is the total [Do] will make before giving up.
	//
	// Both Attempt and Attempts are 0 for a PRE-EMPTIVE wait — one [Gate.Admit]
	// took before any request was made. There is no attempt loop there, and
	// reporting "attempt 0 of 5" would leave an operator looking for four
	// retries that are not going to happen.
	Attempts int
	// Delay is how long the pause will last.
	Delay time.Duration
	// Until is the clock time the pause ends. This is the "until when" an
	// operator actually needs; Delay alone leaves them subtracting.
	Until time.Time
	// Dictated distinguishes a wait the MECHANISM named — a retry-after
	// header, a reset timestamp — from a jittered backoff the gate computed
	// because the mechanism named none. An operator reading a log of
	// undictated waits knows the remedy is less concurrency, not more patience.
	Dictated bool
}

A Wait announces a pause BEFORE it begins, so a sweep that stops for eleven minutes says so. It is what Options.Notify receives.

Visibility is a correctness property here, not ergonomics: a silent pause and a hang are indistinguishable from outside the process, and an operator kills the second one — turning a wait that would have completed into the half-applied sweep the wait existed to prevent.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL