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
- type Clock
- type Event
- type Kind
- type Limiter
- func (l *Limiter) Affordable(writes int) bool
- func (l *Limiter) Await(ctx context.Context, write bool) error
- func (l *Limiter) ExpectWrites(n int)
- func (l *Limiter) Observe(header http.Header)
- func (l *Limiter) PendingWrites() (int, bool)
- func (l *Limiter) Prime(remaining int, reset time.Time)
- func (l *Limiter) Recover(ctx context.Context, err error) (bool, error)
- func (l *Limiter) Remaining() (int, time.Time, bool)
- func (l *Limiter) Waited() time.Duration
- type LiveReporter
- type LoggedReporter
- type Option
- type Reporter
- type SystemClock
- type Transport
- type Wait
Constants ¶
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.
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" )
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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.
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.
type Option ¶
type Option func(*Limiter)
Option configures a Limiter.
func WithJitter ¶
WithJitter replaces the randomisation applied to a secondary backoff. Tests inject the identity, which is what makes the doubling assertable.
func WithMaxWait ¶
WithMaxWait caps the total time spent waiting — the --max-wait flag.
func WithReporter ¶
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 ¶
WithThreshold sets how much primary budget must remain before the limiter waits for the reset.
func WithWriteRate ¶
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 ¶
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 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.
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.