ratelimit

package
v0.1.0-rc.4 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package ratelimit keeps a run inside GitHub's limits, proactively and reactively.

Reads are already cheap — roughly 51 requests for 50 repositories against a 5,000/hour budget. **Writes are what need managing**: a few hundred label operations against a content-creation ceiling of roughly 80 a minute is this tool's most likely failure mode, and it is an undocumented, body-shaped secondary limit rather than a header-shaped primary one.

Proactive, then reactive

  • A token bucket paces writes at DefaultWriteRate a minute, under the ceiling, so the limit is usually never reached at all.
  • Every response's x-ratelimit-remaining and x-ratelimit-reset are read, and a budget running out means sleeping until the reset **before** issuing the next request rather than racing into a 403.
  • When a limit is hit anyway, Limiter.Recover waits it out: until Rate.Reset for a primary limit, and Retry-After — or a jittered backoff from a minute — for a secondary one.

Nothing here sleeps for real under test

Every wait goes through an injected Clock. A rate-limit suite that waits out its own backoffs is a suite nobody runs, and one that asserts on wall-clock time is one that fails on a loaded machine.

Index

Constants

View Source
const (
	// DefaultWriteRate is the ceiling on label writes per minute. GitHub's
	// content-creation limit is roughly 80/minute and undocumented, so the
	// default sits under it with room to spare.
	DefaultWriteRate = 70

	// DefaultMaxWait caps the total time a run may spend sleeping for limits. A
	// CI job should fail with a clear reason rather than idle for an hour
	// burning minutes.
	DefaultMaxWait = 15 * time.Minute

	// DefaultThreshold is how much primary budget has to be left before the
	// limiter stops and waits for the reset. It is not zero: a run that spends
	// its last request discovering it has none left has already lost, and the
	// margin absorbs the requests in flight when the reading was taken.
	DefaultThreshold = 20
)

Defaults. The command's flags carry the same values; these are what a caller that passes nothing gets.

View Source
const EventName = "rate_limit_wait"

EventName is the "event" discriminator of a countdown object, so a consumer can `jq 'select(.event == "rate_limit_wait")'` the waits out of the stderr stream. Added to, never renamed.

Variables

This section is empty.

Functions

This section is empty.

Types

type Clock

type Clock interface {
	// Now is the current time.
	Now() time.Time

	// Sleep waits for d, or until ctx is done — whichever comes first. A
	// cancelled run must not sit out a backoff whose result nothing will use.
	Sleep(ctx context.Context, d time.Duration) error
}

Clock is the limiter's view of time. Production uses SystemClock; tests inject one that records what it was asked to wait and returns immediately.

type Event

type Event struct {
	Level           string `json:"level"` // always "warn"
	Event           string `json:"event"` // always EventName
	Kind            Kind   `json:"kind"`
	Seconds         int    `json:"seconds"`
	ResumeAt        string `json:"resume_at"` // RFC 3339, UTC
	WritesRemaining *int   `json:"writes_remaining,omitempty"`
}

Event is the JSON object a countdown emits on stderr.

The field names are a wire contract. Seconds is a number rather than "04:32" because a consumer that has to parse a duration back out of prose is a consumer the fields exist to spare.

type Kind

type Kind string

Kind names which limit is being waited out. The strings are a wire contract — they are the "kind" field of the JSON event — so they may be added to and never renamed.

const (
	// KindPrimary is the hourly, header-shaped budget: 5,000 requests, and a 403
	// once it is gone.
	KindPrimary Kind = "primary"

	// KindSecondary is the undocumented content-creation limit of roughly 80
	// writes a minute. It is this tool's most likely wait.
	KindSecondary Kind = "secondary"

	// KindBudget is the proactive pause: the primary budget has dropped to its
	// last few requests and the run stops before spending them rather than racing
	// into a 403.
	KindBudget Kind = "budget"
)

func (Kind) Label

func (k Kind) Label() string

Label is the kind in prose, for the line a human reads.

type Limiter

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

Limiter paces requests and waits out limits. The zero value is not usable; build one with New.

It is safe for concurrent use: reads run in parallel, and every one of them reports what it saw into the same budget.

func New

func New(opts ...Option) *Limiter

New builds a limiter. The bucket starts full: a run's first writes should go out immediately, and pacing is about the sustained rate rather than the first one.

func (*Limiter) Affordable

func (l *Limiter) Affordable(writes int) bool

Affordable reports whether writes requests fit in what is known to be left.

It is deliberately a question and not a refusal: this package knows the budget, and the command knows whether a half-finished apply is worse than none at all. An unknown budget is affordable — refusing on no information would stop a run that would have succeeded.

func (*Limiter) Await

func (l *Limiter) Await(ctx context.Context, write bool) error

Await blocks until the next request may go out.

write says whether the request creates content, which is the only kind the bucket paces. The primary-budget check applies to both: a read is cheap, but a read issued with nothing left in the budget is still a 403.

func (*Limiter) ExpectWrites

func (l *Limiter) ExpectWrites(n int)

ExpectWrites tells the limiter how many writes the run is about to make, so that a countdown can report what is left of the job alongside what is left of the wait.

It is what turns "resuming in 04:32" into "resuming in 04:32 · 143 writes remaining": the first is a delay, and the second is progress. Nothing is reported about the count until a caller sets one — an apply knows its plan, and a dry run has nothing to say.

func (*Limiter) Observe

func (l *Limiter) Observe(header http.Header)

Observe records what a response said about the primary budget.

It is called for every response, including the ones that failed: a 403 that is a rate limit carries the same headers, and those are the ones worth having.

func (*Limiter) PendingWrites

func (l *Limiter) PendingWrites() (int, bool)

PendingWrites is how many of the expected writes have not gone out yet, and whether a count was ever set.

It is an estimate in one direction: a write retried after a 5xx or a rate limit spends another token and so counts again, which makes the number conservative rather than optimistic. A progress indicator that overstates what is left is a better failure than one that reaches zero and keeps going.

func (*Limiter) Prime

func (l *Limiter) Prime(remaining int, reset time.Time)

Prime seeds the budget from the free GET /rate_limit at startup, so the first request of a run is issued as informed as the last.

func (*Limiter) Recover

func (l *Limiter) Recover(ctx context.Context, err error) (bool, error)

Recover waits out a rate limit, and reports whether the request should be retried.

An error that is not a rate limit returns (false, nil): it is the caller's, and this package has nothing to say about it.

A wait that would take the run past --max-wait is refused with labelsync.ErrMaxWaitExceeded rather than taken, and the message says how long was asked for and how much of the budget is left — a CI job that idles for an hour and then fails has wasted both the hour and the reason.

func (*Limiter) Remaining

func (l *Limiter) Remaining() (int, time.Time, bool)

Remaining is the last primary budget reading, and whether there has been one. It is what --debug reports and what an apply consults before starting.

func (*Limiter) Waited

func (l *Limiter) Waited() time.Duration

Waited is how long the run has spent asleep for limits so far.

type LiveReporter

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

LiveReporter rewrites one line in place. It is the only rendering that emits a control character, and NewReporter reaches it only when stderr is a terminal.

func NewLiveReporter

func NewLiveReporter(stderr io.Writer) *LiveReporter

NewLiveReporter is the in-place rendering, over a stream it assumes is a terminal. NewReporter is what a command calls; this is exported so a test can drive the rendering the constructor would have chosen without needing a terminal to be handed one.

func (*LiveReporter) Done

func (c *LiveReporter) Done(Wait)

Done implements Reporter, clearing the line it drew.

Clearing rather than leaving it: the wait is over, so a line saying how long is left of it is stale the moment it stops being redrawn, and the run's real output is about to continue on the same row.

func (*LiveReporter) Interval

func (c *LiveReporter) Interval() time.Duration

Interval implements Reporter.

func (*LiveReporter) Start

func (c *LiveReporter) Start(Wait)

Start implements Reporter. Nothing is drawn: the first Tick follows immediately and would overwrite it.

func (*LiveReporter) Tick

func (c *LiveReporter) Tick(w Wait, left time.Duration)

Tick implements Reporter, rewriting the line from column zero.

type LoggedReporter

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

LoggedReporter reports at a fixed interval through the writer, which renders it as a warning line for a human and as a structured event under --output=json.

One implementation covers two of the three renderings, because that is what output.Writer is for: the difference between a log line and a JSON event is the writer's business, not the countdown's. **No control characters**, in either — a `\r` in a CI log is unreadable, and a `\r` in a JSON stream is worse than unreadable.

func NewLoggedReporter

func NewLoggedReporter(w output.Writer) *LoggedReporter

NewLoggedReporter is the periodic rendering, over a writer that decides whether it comes out as a log line or a structured event.

func (*LoggedReporter) Done

func (c *LoggedReporter) Done(Wait)

Done implements Reporter. Nothing is emitted: the last tick already said what was left, and a line saying the wait is over adds no information a consumer cannot get from the next thing that happens.

func (*LoggedReporter) Interval

func (c *LoggedReporter) Interval() time.Duration

Interval implements Reporter.

func (*LoggedReporter) Start

func (c *LoggedReporter) Start(Wait)

Start implements Reporter.

func (*LoggedReporter) Tick

func (c *LoggedReporter) Tick(w Wait, left time.Duration)

Tick implements Reporter.

type Option

type Option func(*Limiter)

Option configures a Limiter.

func WithClock

func WithClock(c Clock) Option

WithClock replaces the clock. This is what keeps the suite instant.

func WithJitter

func WithJitter(f func(time.Duration) time.Duration) Option

WithJitter replaces the randomisation applied to a secondary backoff. Tests inject the identity, which is what makes the doubling assertable.

func WithMaxWait

func WithMaxWait(d time.Duration) Option

WithMaxWait caps the total time spent waiting — the --max-wait flag.

func WithReporter

func WithReporter(r Reporter) Option

WithReporter installs the countdown. Without one a wait is silent, which is the right behaviour for a library and the wrong one for a CLI that has just gone quiet for four minutes.

func WithThreshold

func WithThreshold(n int) Option

WithThreshold sets how much primary budget must remain before the limiter waits for the reset.

func WithWriteRate

func WithWriteRate(perMinute int) Option

WithWriteRate sets the writes-per-minute ceiling — the --write-rate flag. Non-positive disables pacing, which is a thing to do on a single-repository run and a thing to regret on fifty.

type Reporter

type Reporter interface {
	// Interval is how often Tick wants to be called.
	Interval() time.Duration

	// Start opens the wait. It is called once, before the first Tick.
	Start(w Wait)

	// Tick reports that left remains. It is called at least once, immediately,
	// because a wait shorter than one interval still has to say it is happening.
	Tick(w Wait, left time.Duration)

	// Done closes the wait, whether it finished or was cut short by a cancelled
	// context. A rendering that draws control characters has to undraw them here.
	Done(w Wait)
}

Reporter draws a wait while the limiter sits it out.

The limiter owns the sleeping, and calls Reporter.Tick every Reporter.Interval with what is left. That split is what keeps the whole thing testable: nothing here reads a clock, so a fake one drives the animation as fast as the test can run.

func NewReporter

func NewReporter(w output.Writer, stderr io.Writer, format output.Format) Reporter

NewReporter picks the rendering from what --output asked for and what stderr turns out to be.

stderr is the stream being drawn to and the stream asked about, and it is passed raw as well as through the writer: the in-place rendering needs to write a carriage return with no newline after it, which is a thing no output.Writer method can do and should not learn to.

type SystemClock

type SystemClock struct{}

SystemClock is the real one.

func (SystemClock) Now

func (SystemClock) Now() time.Time

Now implements Clock.

func (SystemClock) Sleep

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

Sleep implements Clock.

type Transport

type Transport struct {
	Next    http.RoundTripper
	Limiter *Limiter
}

Transport applies a Limiter to every request that passes through it, and records what every response says about the budget.

It is a RoundTripper rather than a wrapper at each call site because the HTTP method is the only reliable answer to "is this a write". A call site can label itself wrong; a POST cannot. It also catches the requests nothing in this tree issues explicitly — the ones go-github makes on its own for pagination.

It sits **under** the 5xx retry wrapper, closest to the network, so that a retried attempt is paced and observed like any other request rather than slipping past the bucket because the first attempt already paid for it.

func (*Transport) RoundTrip

func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

type Wait

type Wait struct {
	// Kind is which limit is being waited out.
	Kind Kind

	// Total is how long the wait is altogether.
	Total time.Duration

	// ResumeAt is when it ends, on the limiter's clock.
	ResumeAt time.Time

	// Writes is how many writes the run still has to make, and WritesKnown says
	// whether anything ever told the limiter. A dry run has not, and a countdown
	// that invented a zero would report a finished job.
	Writes      int
	WritesKnown bool
}

Wait is one wait, as the reporter is told about it.

Jump to

Keyboard shortcuts

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