Diagnostic GitHub API
the client ignores retry-after and keeps hammering the API
The log shows four hundred consecutive 403s, one second apart, for eleven minutes. The retry logic is working perfectly: it catches the error, waits, tries again, never gives up. GitHub said retry-after: 120 on the very first one, and the client threw that header away along with the rest of the response.
Throttled responses carry the answer. On a secondary limit you get retry-after in seconds; on a primary exhaustion you get x-ratelimit-remaining: 0 and x-ratelimit-reset as an epoch timestamp. Branch on those two in that order, and only fall back to exponential backoff when neither is present.
What that saves is measurable. A client retrying every second inside a 120-second window issues 120 requests that were refused before they were sent, each one keeping the limit engaged. The script below reads the headers from a live probe or from a response you paste in from your logs, computes the exact wait, and reports how many of your retries land inside it.
The problem in plain words
Retry logic is usually written once, early, against a generic HTTP client, and it is written for the failure everyone has seen: a transient connection reset that clears in a moment. So the shape is except: sleep(1); retry, and it is correct for that failure. Applied to a rate limit it is not just useless, it is actively harmful, because the requests it sends during the penalty window are themselves the thing being penalised.
The result is a failure mode that looks like persistence. The process is running. It is making requests. The logs are full. Nothing has crashed, no alert has a threshold for "same status code four hundred times", and the job that should have paused for two minutes and finished is now eleven minutes in and no closer.
And the two throttles want different waits, which is where the half-fixed version goes wrong. A team adds retry-after handling, the secondary-limit case gets better, and then a primary exhaustion arrives with no retry-after at all — that one is signalled by x-ratelimit-reset, up to an hour away — and the client falls straight back to hammering.
Why it happens
Two throttles, two headers, one status code. A secondary limit answers 403 or 429 with retry-after and usually a wait measured in minutes. A primary exhaustion answers 403 with x-ratelimit-remaining: 0 and x-ratelimit-reset, and the wait is however much of the hour is left. Reading only one of the two headers handles half the cases and looks like it handles all of them.
retry-after is not always a number. The HTTP specification allows either a delay in seconds or an HTTP-date. GitHub sends seconds, but a proxy in front of your client can rewrite it, and a parser that does int(value) and swallows the exception silently falls through to the default. Parse both forms and compute the delay against the current time.
Retrying inside the window extends the window. Secondary limits are throttles on burst behaviour. Requests made while one is engaged are burst behaviour, so a client that keeps trying keeps supplying the evidence. This is why the observed pause is so often much longer than the retry-after value the first response contained.
Exponential backoff is the fallback, not the strategy. It is what you use when the server told you nothing. When the server told you exactly how long to wait, backing off exponentially from one second is a worse guess than the answer you were handed, and it is a guess that starts by being wrong 119 times.
The client's behaviour is a blind spot from the API side. Nothing GitHub exposes can tell you whether your code honours these headers; that lives in your code and in your request timestamps. What a read-only script can do is take a throttled response and show you the correct wait next to the wait your current settings would produce, which turns an argument about retry policy into a number.
The fix, as a flow
Every decision in this script is a function of five header values and the current time, so the whole calculation is pure and a response captured during an incident can be costed afterwards, offline.
How to fix it
Capture a real throttled response, headers and all
The next time a job is throttled, log the full response headers, not just the status. retry-after, x-ratelimit-remaining, x-ratelimit-reset, x-ratelimit-resource and x-github-request-id are the five that matter. The script accepts them on the command line so you can evaluate an incident after the fact, offline.
Branch on retry-after first
If retry-after is present, that is the wait. Sleep exactly that long — not a fraction, not a capped version of it — and parse the HTTP-date form as well as the integer form. This branch has to come first because a secondary limit can arrive while the primary bucket is perfectly healthy, and the reset timestamp then tells you nothing useful.
Fall back to x-ratelimit-reset when the bucket is empty
x-ratelimit-remaining: 0 means the hourly quota is gone and x-ratelimit-reset is the epoch second it returns. Sleep until then. It can be most of an hour, which is unpleasant and is still shorter than an hour of refused retries followed by the same wait.
Only then back off exponentially, with jitter and a cap
No headers means no information, so guess: one second, two, four, eight, capped at a minute, with random jitter so that a fleet of workers does not synchronise into a thundering herd, and with a maximum attempt count so a permanent failure eventually surfaces as one.
Pause the whole client, and count what you would have wasted
A throttle applies to the credential, not the request, so every other worker holding that token is about to be refused too. Sleep the shared client rather than the one call. Then run the script against the captured response: the number of retries your current interval fits inside the required wait is the size of the problem, stated plainly.
How to check it worked
Feed the script the headers from a throttled response and confirm the wait it computes matches what you now sleep, with no requests scheduled inside the window.
python3 github_backoff_plan.py --status 403 --header 'retry-after: 120' \
--header 'x-ratelimit-remaining: 4870' --interval 1
# hammering: wait 120s from retry-after; a 1.0s interval sends 120 refused requests
The full code
The interesting code here has no network in it at all. Everything that decides how long to wait is pure and takes now as an argument, because the whole point is that the decision is a function of five header values and nothing else. The script's one live request exists only to fetch a real set of headers; --status and --header let you run the same analysis over a response you already captured.
"""Compute the wait a throttled GitHub response asks for, and cost your retries.
Read only. The single live request is a GET against /rate_limit, which does not
count against the primary rate limit. Everything that decides the wait is a pure
function of the response headers, so a response captured during an incident can
be analysed later with --status and --header.
Whether your client honours these headers is not visible through the API: it
lives in your code. What is visible is the contract, and what it costs to ignore.
"""
import argparse
import logging
import os
import sys
import time
from email.utils import parsedate_to_datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_backoff_plan")
API = "https://api.github.com"
UA = "github-backoff-plan/1.0"
# Where a secondary limit sends no retry-after, the documented advice is to wait
# at least a minute before trying again.
SECONDARY_FLOOR_SECONDS = 60.0
def retry_after_seconds(value, now):
"""Parse a retry-after header into seconds from now, or None. Pure.
HTTP allows either a delay in seconds or an HTTP-date. GitHub sends seconds,
but a proxy in front of the client is free to rewrite it into the other form,
and a parser that only does int() treats that as absent and falls through to
a default that is usually far too short.
"""
text = str(value or "").strip()
if not text:
return None
try:
return max(0.0, float(int(text)))
except ValueError:
pass
try:
when = parsedate_to_datetime(text)
except (TypeError, ValueError):
return None
if when is None:
return None
return max(0.0, when.timestamp() - float(now))
def required_wait(status, headers, now):
"""How long a correct client sleeps before its next request. Pure.
Returns (seconds, source, detail). The order is not arbitrary: a secondary
limit can fire while the primary bucket is untouched, so retry-after has to
win over the reset timestamp, which in that case is describing an hour that
has nothing to do with why this request was refused.
"""
lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
try:
status = int(status)
except (TypeError, ValueError):
status = 0
if status not in (403, 429):
return (0.0, "none",
"%d is not a throttled response, so there is nothing to wait for"
% status)
seconds = retry_after_seconds(lowered.get("retry-after"), now)
if seconds is not None:
return (seconds, "retry-after",
"the response asked for %.0f second(s). Sleep exactly that, not "
"a capped or scaled version of it." % seconds)
try:
remaining = int(lowered.get("x-ratelimit-remaining"))
except (TypeError, ValueError):
remaining = None
try:
reset = float(lowered.get("x-ratelimit-reset"))
except (TypeError, ValueError):
reset = None
if remaining == 0 and reset is not None:
return (max(0.0, reset - float(now)), "x-ratelimit-reset",
"the hourly quota is spent and returns at the reset timestamp, "
"%.0f second(s) from now" % max(0.0, reset - float(now)))
return (SECONDARY_FLOOR_SECONDS, "floor",
"no retry-after and the primary bucket is not empty, so this is a "
"secondary limit that sent no wait. Treat %.0f seconds as the floor "
"and back off exponentially from there."
% SECONDARY_FLOOR_SECONDS)
def backoff(attempt, base=1.0, cap=60.0):
"""Exponential delay for a given attempt number. Pure, and unjittered.
The fallback for when the server said nothing at all. Jitter is applied by the
caller rather than in here, so that the schedule this returns is something the
tests can assert on and a reader can predict.
"""
attempt = max(0, int(attempt))
return min(float(cap), float(base) * (2 ** attempt))
def wasted_requests(seconds, interval):
"""How many refused requests a fixed-interval retrier fits in the wait. Pure.
This is the number that makes the argument. Every one of these is sent into a
limit that is already engaged, and on a secondary limit each one is fresh
evidence of the burst behaviour being throttled.
"""
seconds = max(0.0, float(seconds))
interval = float(interval)
if interval <= 0:
return 0
return int(seconds // interval)
def plan(status, headers, now, interval=1.0):
"""Turn a throttled response into a finding. Pure. Returns (state, report)."""
seconds, source, detail = required_wait(status, headers, now)
wasted = wasted_requests(seconds, interval)
report = {"wait_seconds": round(seconds, 1), "source": source,
"detail": detail, "wasted_requests": wasted,
"retry_interval": interval,
"fallback_schedule": [backoff(i) for i in range(5)]}
if source == "none":
return ("not-throttled", report)
if wasted >= 60:
return ("hammering", report)
if wasted > 0:
return ("impatient", report)
return ("honoured", report)
def parse_header(text):
"""'Name: value' from the command line into a (name, value) pair."""
name, _, value = str(text).partition(":")
return (name.strip(), value.strip())
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--status", type=int, default=None,
help="analyse a captured response with this status instead "
"of probing the API")
ap.add_argument("--header", action="append", default=[],
help="'name: value' from a captured response; repeatable")
ap.add_argument("--interval", type=float, default=1.0,
help="the retry interval your client currently uses")
args = ap.parse_args()
now = time.time()
if args.status is not None:
status = args.status
headers = dict(parse_header(h) for h in args.header)
log.info("analysing a captured %d with %d header(s)", status, len(headers))
else:
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough), or pass "
"--status and --header to analyse a captured response")
return 2
r = requests.get(API + "/rate_limit", timeout=30, headers={
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
status, headers = r.status_code, dict(r.headers)
log.info("probed GET /rate_limit: %d (this endpoint does not consume "
"quota)", status)
state, report = plan(status, headers, now, args.interval)
log.info("%s: wait %.0fs from %s", state, report["wait_seconds"],
report["source"])
log.info(" %s", report["detail"])
if state == "not-throttled":
log.info(" nothing is throttled right now. Re-run with --status and "
"--header against a response captured during an incident to "
"cost your current retry policy.")
return 0
log.warning(" a %.1fs retry interval sends %d refused request(s) inside "
"that window", report["retry_interval"], report["wasted_requests"])
log.warning(" repair: sleep the whole client for %.0f second(s) before the "
"next request, not one call.", report["wait_seconds"])
log.warning(" repair: branch on retry-after first, then on "
"x-ratelimit-remaining being 0 plus x-ratelimit-reset, and only "
"then on a jittered exponential schedule such as %s",
", ".join("%.0fs" % s for s in report["fallback_schedule"]))
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Compute the wait a throttled GitHub response asks for, and cost your retries.
*
* Read only. The single live request is a GET against /rate_limit, which does
* not count against the primary rate limit. Everything that decides the wait is
* a pure function of the response headers.
*/
const API = 'https://api.github.com';
const UA = 'github-backoff-plan/1.0';
// Where a secondary limit sends no retry-after, the documented advice is to
// wait at least a minute before trying again.
export const SECONDARY_FLOOR_SECONDS = 60;
/**
* Parse a retry-after header into seconds from now, or null. Pure.
* HTTP allows either a delay in seconds or an HTTP-date, and a parser that only
* handles the integer form treats the other as absent.
*/
export function retryAfterSeconds(value, now) {
const text = String(value ?? '').trim();
if (!text) return null;
if (/^\d+$/.test(text)) return Math.max(0, Number.parseInt(text, 10));
const ms = Date.parse(text);
if (!Number.isFinite(ms)) return null;
return Math.max(0, ms / 1000 - Number(now));
}
/**
* How long a correct client sleeps before its next request. Pure.
* Returns [seconds, source, detail]. retry-after wins over the reset timestamp
* because a secondary limit can fire while the primary bucket is untouched.
*/
export function requiredWait(status, headers, now) {
const lowered = {};
for (const [k, v] of Object.entries(headers ?? {})) lowered[k.toLowerCase()] = v;
const code = Number.parseInt(status, 10) || 0;
if (code !== 403 && code !== 429) {
return [0, 'none',
`${code} is not a throttled response, so there is nothing to wait for`];
}
const seconds = retryAfterSeconds(lowered['retry-after'], now);
if (seconds !== null) {
return [seconds, 'retry-after',
`the response asked for ${Math.round(seconds)} second(s). Sleep exactly ` +
'that, not a capped or scaled version of it.'];
}
const remainingRaw = Number.parseInt(lowered['x-ratelimit-remaining'], 10);
const remaining = Number.isFinite(remainingRaw) ? remainingRaw : null;
const resetRaw = Number.parseFloat(lowered['x-ratelimit-reset']);
const reset = Number.isFinite(resetRaw) ? resetRaw : null;
if (remaining === 0 && reset !== null) {
const wait = Math.max(0, reset - Number(now));
return [wait, 'x-ratelimit-reset',
'the hourly quota is spent and returns at the reset timestamp, ' +
`${Math.round(wait)} second(s) from now`];
}
return [SECONDARY_FLOOR_SECONDS, 'floor',
'no retry-after and the primary bucket is not empty, so this is a secondary ' +
`limit that sent no wait. Treat ${SECONDARY_FLOOR_SECONDS} seconds as the ` +
'floor and back off exponentially from there.'];
}
/**
* Exponential delay for a given attempt number. Pure, and unjittered.
* Jitter belongs to the caller so this schedule stays predictable.
*/
export function backoff(attempt, base = 1, cap = 60) {
const n = Math.max(0, Math.trunc(attempt));
return Math.min(cap, base * (2 ** n));
}
/**
* How many refused requests a fixed-interval retrier fits in the wait. Pure.
* Every one of these is sent into a limit that is already engaged.
*/
export function wastedRequests(seconds, interval) {
const wait = Math.max(0, Number(seconds));
const gap = Number(interval);
if (!(gap > 0)) return 0;
return Math.floor(wait / gap);
}
/** Turn a throttled response into a finding. Pure. Returns [state, report]. */
export function plan(status, headers, now, interval = 1) {
const [seconds, source, detail] = requiredWait(status, headers, now);
const wasted = wastedRequests(seconds, interval);
const report = {
wait_seconds: Math.round(seconds * 10) / 10,
source,
detail,
wasted_requests: wasted,
retry_interval: interval,
fallback_schedule: [0, 1, 2, 3, 4].map((i) => backoff(i)),
};
if (source === 'none') return ['not-throttled', report];
if (wasted >= 60) return ['hammering', report];
if (wasted > 0) return ['impatient', report];
return ['honoured', report];
}
function parseHeader(text) {
const at = String(text).indexOf(':');
if (at < 0) return [String(text).trim(), ''];
return [String(text).slice(0, at).trim(), String(text).slice(at + 1).trim()];
}
async function main() {
const args = process.argv.slice(2);
const now = Date.now() / 1000;
let status = null;
let interval = 1;
const headers = {};
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--status') { status = Number.parseInt(args[i + 1], 10); i += 1; }
else if (args[i] === '--interval') { interval = Number.parseFloat(args[i + 1]); i += 1; }
else if (args[i] === '--header') {
const [name, value] = parseHeader(args[i + 1]);
headers[name] = value;
i += 1;
}
}
let live = headers;
if (status === null) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error('set GITHUB_TOKEN (a read-only token is enough), or pass ' +
'--status and --header to analyse a captured response');
process.exitCode = 2;
return;
}
const res = await fetch(`${API}/rate_limit`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
status = res.status;
live = Object.fromEntries(res.headers.entries());
console.log(`probed GET /rate_limit: ${status} (this endpoint does not consume quota)`);
} else {
console.log(`analysing a captured ${status} with ${Object.keys(headers).length} header(s)`);
}
const [state, report] = plan(status, live, now, interval);
console.log(`${state}: wait ${Math.round(report.wait_seconds)}s from ${report.source}`);
console.log(` ${report.detail}`);
if (state === 'not-throttled') {
console.log(' nothing is throttled right now. Re-run with --status and ' +
'--header against a response captured during an incident to cost your ' +
'current retry policy.');
return;
}
console.warn(` a ${report.retry_interval}s retry interval sends ` +
`${report.wasted_requests} refused request(s) inside that window`);
console.warn(` repair: sleep the whole client for ${Math.round(report.wait_seconds)} ` +
'second(s) before the next request, not one call.');
console.warn(' repair: branch on retry-after first, then on ' +
'x-ratelimit-remaining being 0 plus x-ratelimit-reset, and only then on a ' +
`jittered exponential schedule such as ${report.fallback_schedule.join('s, ')}s`);
process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Every case here is a set of headers and a fixed now, which is the whole reason the wait calculation was written as a pure function: a throttled response is not something you can produce on demand, and a test that needed one would never run. The cases that matter are the precedence between the two headers, the HTTP-date form of retry-after that a proxy can introduce, and the difference between a bucket at zero and a bucket that was never read.
from github_backoff_plan import (backoff, plan, required_wait,
retry_after_seconds, wasted_requests)
NOW = 1756512000.0 # 2025-08-30T00:00:00Z
def test_retry_after_reads_the_integer_form():
assert retry_after_seconds("120", NOW) == 120.0
assert retry_after_seconds(" 60 ", NOW) == 60.0
def test_retry_after_reads_the_http_date_form_a_proxy_may_substitute():
assert retry_after_seconds("Sat, 30 Aug 2025 00:02:00 GMT", NOW) == 120.0
def test_a_retry_after_already_in_the_past_is_zero_not_negative():
assert retry_after_seconds("Fri, 29 Aug 2025 23:00:00 GMT", NOW) == 0.0
def test_an_unparseable_retry_after_is_absent_rather_than_zero():
assert retry_after_seconds("soon", NOW) is None
assert retry_after_seconds(None, NOW) is None
assert retry_after_seconds("", NOW) is None
def test_retry_after_wins_over_the_reset_timestamp():
# A secondary limit fires with the hourly bucket untouched, so the reset
# timestamp is describing an hour that has nothing to do with this refusal.
seconds, source, _ = required_wait(403, {
"Retry-After": "120",
"X-RateLimit-Remaining": "4870",
"X-RateLimit-Reset": str(int(NOW + 3000)),
}, NOW)
assert source == "retry-after"
assert seconds == 120.0
def test_an_empty_bucket_falls_through_to_the_reset_timestamp():
seconds, source, detail = required_wait(403, {
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": str(int(NOW + 1800)),
}, NOW)
assert source == "x-ratelimit-reset"
assert seconds == 1800.0
assert "hourly quota" in detail
def test_a_bucket_with_headroom_and_no_retry_after_uses_the_floor():
seconds, source, _ = required_wait(429, {"x-ratelimit-remaining": "4900"}, NOW)
assert source == "floor"
assert seconds == 60.0
def test_a_response_that_is_not_throttled_asks_for_no_wait():
seconds, source, _ = required_wait(200, {"retry-after": "120"}, NOW)
assert source == "none"
assert seconds == 0.0
def test_backoff_doubles_and_then_stops_at_the_cap():
assert [backoff(i) for i in range(5)] == [1.0, 2.0, 4.0, 8.0, 16.0]
assert backoff(20) == 60.0
assert backoff(-3) == 1.0
def test_wasted_requests_counts_what_fits_inside_the_wait():
assert wasted_requests(120, 1) == 120
assert wasted_requests(120, 30) == 4
assert wasted_requests(0, 1) == 0
def test_wasted_requests_survives_a_nonsense_interval():
assert wasted_requests(120, 0) == 0
assert wasted_requests(120, -5) == 0
def test_a_one_second_retry_inside_a_two_minute_wait_is_hammering():
state, report = plan(403, {"retry-after": "120"}, NOW, 1.0)
assert state == "hammering"
assert report["wasted_requests"] == 120
assert report["source"] == "retry-after"
def test_a_client_that_waits_longer_than_asked_has_honoured_it():
state, report = plan(403, {"retry-after": "120"}, NOW, 300.0)
assert state == "honoured"
assert report["wasted_requests"] == 0
def test_a_few_retries_inside_the_window_are_impatient_not_hammering():
state, _ = plan(429, {"retry-after": "120"}, NOW, 30.0)
assert state == "impatient"
def test_an_untroubled_response_reports_nothing_to_do():
state, report = plan(200, {}, NOW, 1.0)
assert state == "not-throttled"
assert report["wait_seconds"] == 0.0
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
backoff, plan, requiredWait, retryAfterSeconds, wastedRequests,
} from './github-backoff-plan.mjs';
const NOW = 1756512000; // 2025-08-30T00:00:00Z
test('retry-after reads the integer form', () => {
assert.equal(retryAfterSeconds('120', NOW), 120);
assert.equal(retryAfterSeconds(' 60 ', NOW), 60);
});
test('retry-after reads the HTTP-date form a proxy may substitute', () => {
assert.equal(retryAfterSeconds('Sat, 30 Aug 2025 00:02:00 GMT', NOW), 120);
});
test('a retry-after already in the past is zero, not negative', () => {
assert.equal(retryAfterSeconds('Fri, 29 Aug 2025 23:00:00 GMT', NOW), 0);
});
test('an unparseable retry-after is absent rather than zero', () => {
assert.equal(retryAfterSeconds('soon', NOW), null);
assert.equal(retryAfterSeconds(null, NOW), null);
assert.equal(retryAfterSeconds('', NOW), null);
});
test('retry-after wins over the reset timestamp', () => {
const [seconds, source] = requiredWait(403, {
'Retry-After': '120',
'X-RateLimit-Remaining': '4870',
'X-RateLimit-Reset': String(NOW + 3000),
}, NOW);
assert.equal(source, 'retry-after');
assert.equal(seconds, 120);
});
test('an empty bucket falls through to the reset timestamp', () => {
const [seconds, source, detail] = requiredWait(403, {
'x-ratelimit-remaining': '0',
'x-ratelimit-reset': String(NOW + 1800),
}, NOW);
assert.equal(source, 'x-ratelimit-reset');
assert.equal(seconds, 1800);
assert.match(detail, /hourly quota/);
});
test('a bucket with headroom and no retry-after uses the floor', () => {
const [seconds, source] = requiredWait(429, { 'x-ratelimit-remaining': '4900' }, NOW);
assert.equal(source, 'floor');
assert.equal(seconds, 60);
});
test('a response that is not throttled asks for no wait', () => {
const [seconds, source] = requiredWait(200, { 'retry-after': '120' }, NOW);
assert.equal(source, 'none');
assert.equal(seconds, 0);
});
test('backoff doubles and then stops at the cap', () => {
assert.deepEqual([0, 1, 2, 3, 4].map((i) => backoff(i)), [1, 2, 4, 8, 16]);
assert.equal(backoff(20), 60);
assert.equal(backoff(-3), 1);
});
test('wastedRequests counts what fits inside the wait', () => {
assert.equal(wastedRequests(120, 1), 120);
assert.equal(wastedRequests(120, 30), 4);
assert.equal(wastedRequests(0, 1), 0);
});
test('wastedRequests survives a nonsense interval', () => {
assert.equal(wastedRequests(120, 0), 0);
assert.equal(wastedRequests(120, -5), 0);
});
test('a one-second retry inside a two-minute wait is hammering', () => {
const [state, report] = plan(403, { 'retry-after': '120' }, NOW, 1);
assert.equal(state, 'hammering');
assert.equal(report.wasted_requests, 120);
assert.equal(report.source, 'retry-after');
});
test('a client that waits longer than asked has honoured it', () => {
const [state, report] = plan(403, { 'retry-after': '120' }, NOW, 300);
assert.equal(state, 'honoured');
assert.equal(report.wasted_requests, 0);
});
test('a few retries inside the window are impatient, not hammering', () => {
assert.equal(plan(429, { 'retry-after': '120' }, NOW, 30)[0], 'impatient');
});
test('an untroubled response reports nothing to do', () => {
const [state, report] = plan(200, {}, NOW, 1);
assert.equal(state, 'not-throttled');
assert.equal(report.wait_seconds, 0);
});
FAQ
Does GitHub always send retry-after when it throttles me?
No. Secondary limits normally carry it; primary exhaustion normally does not, and signals the wait through x-ratelimit-remaining being 0 plus x-ratelimit-reset instead. That is why the branch order matters and why a client that reads only one of the two headers handles half its throttles well and the other half not at all.
Can a script tell whether my client honours retry-after?
Not through the API. GitHub sees your requests, not your code, and exposes nothing about your retry behaviour; that is a genuine blind spot for a read-only observer. What the script does instead is take a throttled response you captured and put the required wait next to the number of retries your current interval would fit inside it, which is the same argument made with a number.
Is it safe to just sleep for an hour whenever I see a 403?
Safe, and usually far more than you need. A secondary limit often clears in a couple of minutes, so an unconditional hour turns a small pause into an outage. Read the headers: they distinguish a two-minute wait from a fifty-minute one, and the whole point is that you do not have to guess.
Why does the throttle last longer than the retry-after value said?
Because the requests you sent while waiting counted. Secondary limits throttle burst behaviour, and retries during the penalty window are burst behaviour, so a client that keeps trying keeps re-arming the limit. Honouring the header exactly is not politeness; it is the shortest path back to working.
Should I add jitter even when I have a retry-after value?
Not to that value; sleep it exactly. Jitter belongs to the fallback schedule, where several workers would otherwise wake at identical moments and re-create the burst that was throttled. A retry-after is a specific instruction and every worker obeying it lands at the same time by design, which is fine because that time is one GitHub chose.
Related field notes
- Over 100 concurrent requests trips a limit
- Bulk creation exceeds 80 a minute
- Polling without ETags spends full quota
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Best practices for using the REST API — GitHub Docs
- Rate limits for the REST API — GitHub Docs
- Troubleshooting the REST API — GitHub Docs
- Rate limit — GitHub REST API
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.