Skip to content

Commit 3d05f11

Browse files
committed
GH Rate Limit Fix
1 parent 76d5921 commit 3d05f11

5 files changed

Lines changed: 245 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ Both inputs select repositories directly, independently of the configured sync s
259259

260260
Override mode stops before any changes if the receiving repository has a label named `.` or `..`, because those names cannot be safely addressed through the label API's URL path.
261261

262+
Transfers pause at least one second between label writes to reduce GitHub secondary rate limits. When GitHub rejects a request due to rate limiting, the workflow logs the wait and retries up to five times, honoring `Retry-After` and exhausted primary-limit reset headers. Retry waits start at one minute and grow exponentially; GitHub's headers can require a longer pause. A transfer creating 233 labels takes roughly four minutes plus API response time and any rate-limit waits. Permission, validation, and ambiguous network/server errors still stop the run. Rerun a partially completed transfer with the same inputs to finish the remaining changes.
263+
262264
The workflow uses the Org-Label-Sync changelog layout in the GitHub Actions run summary, showing the source and receiving repositories, test and override settings, starting label counts, and created, updated, deleted, and retained counts. Retained labels are existing receiving labels left unchanged. Preview changelogs are marked as test-mode output. If the transfer fails partway through, the summary records completed changes and the failure; rerunning continues from the current label state.
263265

264266
### Config-Reset

scripts/lib/github-request.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { setTimeout as sleepTimer } from "node:timers/promises";
2+
3+
const mutationMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
4+
const maxRateLimitRetries = 5;
5+
6+
function retryAfterMilliseconds(value, now) {
7+
if (!value?.trim()) return 0;
8+
const seconds = Number(value);
9+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
10+
const date = Date.parse(value);
11+
return Number.isFinite(date) ? Math.max(0, date - now) : 0;
12+
}
13+
14+
// Keep one client per transfer and call it serially so all label mutations share
15+
// the same pacing window. Only explicit rate-limit rejections are safe to retry.
16+
export function createGithubRequest(token, { sleep = sleepTimer, now = Date.now } = {}) {
17+
let nextWriteAt = 0;
18+
19+
return async (method, apiPath, body) => {
20+
const isMutation = mutationMethods.has(method);
21+
for (let retry = 0; ; retry += 1) {
22+
const pacingDelay = nextWriteAt - now();
23+
if (isMutation && pacingDelay > 0) await sleep(pacingDelay);
24+
25+
const response = await fetch(`https://api.github.com${apiPath}`, {
26+
method,
27+
headers: {
28+
Accept: "application/vnd.github+json",
29+
Authorization: `Bearer ${token}`,
30+
"User-Agent": "label-sync",
31+
"X-GitHub-Api-Version": "2022-11-28",
32+
},
33+
body: body ? JSON.stringify(body) : undefined,
34+
});
35+
if (isMutation) nextWriteAt = now() + 1000;
36+
if (response.ok) return response.status === 204 ? null : response.json();
37+
38+
const message = await response.text();
39+
const primaryLimitExhausted = response.headers.get("x-ratelimit-remaining") === "0";
40+
const rateLimited = response.status === 429 || (response.status === 403 && (
41+
primaryLimitExhausted
42+
|| response.headers.has("retry-after")
43+
|| /rate limit|abuse detection/i.test(message)
44+
));
45+
if (!rateLimited || retry === maxRateLimitRetries) {
46+
const exhausted = rateLimited ? ` (rate limit persisted after ${maxRateLimitRetries} retries)` : "";
47+
throw new Error(`${method} ${apiPath} failed with ${response.status}: ${message}${exhausted}`);
48+
}
49+
50+
const currentTime = now();
51+
const resetSeconds = Number(response.headers.get("x-ratelimit-reset"));
52+
const resetDelay = primaryLimitExhausted && Number.isFinite(resetSeconds) && resetSeconds > 0
53+
? Math.max(0, resetSeconds * 1000 - currentTime + 1000)
54+
: 0;
55+
const waitMilliseconds = Math.max(
56+
60000 * (2 ** retry),
57+
retryAfterMilliseconds(response.headers.get("retry-after"), currentTime),
58+
resetDelay,
59+
);
60+
console.warn(`GitHub rate limit on ${method} ${apiPath}. Waiting ${Math.ceil(waitMilliseconds / 1000)}s before retry ${retry + 1}/${maxRateLimitRetries}.`);
61+
await sleep(waitMilliseconds);
62+
}
63+
};
64+
}

scripts/transfer-labels.mjs

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { assert, labelsExactlyMatch, normalizeDescription, normalizeName } from
44
import { validateLabels } from "./lib/config-validation.mjs";
55
import { renderLabelSyncSection, writeChangelog } from "./lib/changelog-utils.mjs";
66
import { formatRepositoryLink, getRepositorySkipReason, parseTokenPermissions } from "./lib/repository-selection.mjs";
7+
import { createGithubRequest } from "./lib/github-request.mjs";
78

89
function resolveRepository(value, organization, inputName) {
910
const name = typeof value === "string" ? value.trim() : "";
@@ -20,27 +21,10 @@ function resolveRepository(value, organization, inputName) {
2021
return fullName;
2122
}
2223

23-
async function githubRequest(token, method, apiPath, body) {
24-
const response = await fetch(`https://api.github.com${apiPath}`, {
25-
method,
26-
headers: {
27-
Accept: "application/vnd.github+json",
28-
Authorization: `Bearer ${token}`,
29-
"User-Agent": "label-sync",
30-
"X-GitHub-Api-Version": "2022-11-28",
31-
},
32-
body: body ? JSON.stringify(body) : undefined,
33-
});
34-
if (!response.ok) {
35-
throw new Error(`${method} ${apiPath} failed with ${response.status}: ${await response.text()}`);
36-
}
37-
return response.status === 204 ? null : response.json();
38-
}
39-
40-
async function getAllLabels(token, repository) {
24+
async function getAllLabels(githubRequest, repository) {
4125
const labels = [];
4226
for (let page = 1; ; page += 1) {
43-
const batch = await githubRequest(token, "GET", `/repos/${repository}/labels?per_page=100&page=${page}`);
27+
const batch = await githubRequest("GET", `/repos/${repository}/labels?per_page=100&page=${page}`);
4428
assert(Array.isArray(batch), `Invalid label response for ${repository}.`);
4529
labels.push(...batch);
4630
if (batch.length < 100) {
@@ -60,6 +44,7 @@ export async function transferLabels({
6044
dryRun = false,
6145
overrideExisting = false,
6246
tokenPermissions = null,
47+
githubRequest = createGithubRequest(token),
6348
}) {
6449
const result = {
6550
repository: "",
@@ -84,8 +69,8 @@ export async function transferLabels({
8469
targetName = resolveRepository(targetRepository, organization, "Receiving repository");
8570
assert(sourceName.toLowerCase() !== targetName.toLowerCase(), "Source and receiving repositories must be different repositories.");
8671
assert(token, "LABEL_SYNC_TOKEN is required.");
87-
const source = await githubRequest(token, "GET", `/repos/${sourceName}`);
88-
const target = await githubRequest(token, "GET", `/repos/${targetName}`);
72+
const source = await githubRequest("GET", `/repos/${sourceName}`);
73+
const target = await githubRequest("GET", `/repos/${targetName}`);
8974
assert(source.id && target.id, "GitHub did not return valid repository IDs.");
9075
assert(source.id !== target.id, "Source and receiving repositories must be different repositories.");
9176
// Use canonical names after resolving renamed/transferred repository aliases.
@@ -100,9 +85,9 @@ export async function transferLabels({
10085
}
10186

10287
// Read and validate both complete label sets before making any changes.
103-
const sourceLabels = await getAllLabels(token, sourceName);
88+
const sourceLabels = await getAllLabels(githubRequest, sourceName);
10489
sourceCount = sourceLabels.length;
105-
const targetLabels = await getAllLabels(token, targetName);
90+
const targetLabels = await getAllLabels(githubRequest, targetName);
10691
initialTargetCount = targetLabels.length;
10792
if (overrideExisting) {
10893
for (const label of targetLabels) {
@@ -126,14 +111,14 @@ export async function transferLabels({
126111
const existing = targetByName.get(normalizeName(desired.name));
127112
if (!existing) {
128113
if (!dryRun) {
129-
await githubRequest(token, "POST", `/repos/${targetName}/labels`, desired);
114+
await githubRequest("POST", `/repos/${targetName}/labels`, desired);
130115
}
131116
result.createdLabels.push(desired);
132117
result.hasChanges = true;
133118
console.log(` + ${desired.name}`);
134119
} else if (overrideExisting && !labelsExactlyMatch(existing, desired)) {
135120
if (!dryRun) {
136-
await githubRequest(token, "PATCH", `/repos/${targetName}/labels/${encodeURIComponent(existing.name)}`, {
121+
await githubRequest("PATCH", `/repos/${targetName}/labels/${encodeURIComponent(existing.name)}`, {
137122
new_name: desired.name,
138123
color: desired.color,
139124
description: desired.description,
@@ -151,7 +136,7 @@ export async function transferLabels({
151136
for (const existing of targetLabels) {
152137
if (sourceNames.has(normalizeName(existing.name))) continue;
153138
if (!dryRun) {
154-
await githubRequest(token, "DELETE", `/repos/${targetName}/labels/${encodeURIComponent(existing.name)}`);
139+
await githubRequest("DELETE", `/repos/${targetName}/labels/${encodeURIComponent(existing.name)}`);
155140
}
156141
result.deletedConfiguredLabels.push(existing);
157142
result.hasChanges = true;

test/github-request.test.mjs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { createGithubRequest } from "../scripts/lib/github-request.mjs";
4+
5+
function setup(t, responses) {
6+
let time = 1_700_000_000_000;
7+
const waits = [];
8+
const requests = [];
9+
const request = createGithubRequest("test-token", {
10+
now: () => time,
11+
sleep: async (milliseconds) => {
12+
waits.push(milliseconds);
13+
time += milliseconds;
14+
},
15+
});
16+
t.mock.method(globalThis, "fetch", async (url, options) => {
17+
requests.push({ url, ...options, time });
18+
assert.ok(responses.length, "Unexpected extra request");
19+
return responses.shift();
20+
});
21+
return { request, waits, requests };
22+
}
23+
24+
test("paces successive label writes by one second without slowing reads", async (t) => {
25+
const fixture = setup(t, [Response.json([]), Response.json({}), Response.json([]), Response.json({}), new Response(null, { status: 204 })]);
26+
await fixture.request("GET", "/repos/example/repo/labels");
27+
await fixture.request("POST", "/repos/example/repo/labels", { name: "Bug" });
28+
await fixture.request("GET", "/repos/example/repo/labels");
29+
await fixture.request("PATCH", "/repos/example/repo/labels/Bug", { color: "000000" });
30+
assert.equal(await fixture.request("DELETE", "/repos/example/repo/labels/Old"), null);
31+
assert.deepEqual(fixture.waits, [1000, 1000]);
32+
assert.deepEqual(fixture.requests.map(({ time }) => time - 1_700_000_000_000), [0, 0, 0, 1000, 2000]);
33+
});
34+
35+
test("secondary 403 retries the rejected write with the same URL, body and auth", async (t) => {
36+
const fixture = setup(t, [
37+
Response.json({ message: "You have exceeded a secondary rate limit. Please wait a few minutes before you try again." }, { status: 403 }),
38+
Response.json({ name: "Bug" }),
39+
]);
40+
assert.deepEqual(await fixture.request("POST", "/repos/example/repo/labels", { name: "Bug" }), { name: "Bug" });
41+
assert.deepEqual(fixture.waits, [60000]);
42+
assert.equal(fixture.requests.length, 2);
43+
for (const request of fixture.requests) {
44+
assert.equal(request.method, "POST");
45+
assert.equal(request.url, "https://api.github.com/repos/example/repo/labels");
46+
assert.equal(request.body, '{"name":"Bug"}');
47+
assert.equal(request.headers.Authorization, "Bearer test-token");
48+
}
49+
});
50+
51+
for (const [name, headers, expectedWait] of [
52+
["Retry-After seconds", { "retry-after": "180" }, 180000],
53+
["Retry-After HTTP date", { "retry-after": "Tue, 14 Nov 2023 22:16:20 GMT" }, 180000],
54+
["primary reset time", { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1700000180" }, 181000],
55+
["both headers", { "retry-after": "90", "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1700000180" }, 181000],
56+
["invalid headers", { "retry-after": "invalid", "x-ratelimit-remaining": "0", "x-ratelimit-reset": "invalid" }, 60000],
57+
]) {
58+
test(`rate-limit recovery honors ${name}`, async (t) => {
59+
const fixture = setup(t, [new Response("Throttled", { status: 429, headers }), Response.json([])]);
60+
await fixture.request("GET", "/repos/example/repo/labels");
61+
assert.deepEqual(fixture.waits, [expectedWait]);
62+
assert.equal(fixture.requests.length, 2);
63+
});
64+
}
65+
66+
test("primary-limit 403 is retried even without a secondary-limit message", async (t) => {
67+
const fixture = setup(t, [
68+
new Response("Forbidden", { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1700000180" } }),
69+
Response.json([]),
70+
]);
71+
await fixture.request("GET", "/repos/example/repo/labels");
72+
assert.deepEqual(fixture.waits, [181000]);
73+
});
74+
75+
test("persistent rate limits stop after five retries with exponential backoff", async (t) => {
76+
const fixture = setup(t, Array.from({ length: 6 }, () => new Response("Secondary rate limit", { status: 403 })));
77+
await assert.rejects(fixture.request("GET", "/repos/example/repo/labels"), /403.*after 5 retries/);
78+
assert.deepEqual(fixture.waits, [60000, 120000, 240000, 480000, 960000]);
79+
assert.equal(fixture.requests.length, 6);
80+
});
81+
82+
for (const status of [401, 403, 404, 422, 500]) {
83+
test(`ordinary ${status} errors fail immediately without retrying writes`, async (t) => {
84+
const fixture = setup(t, [new Response("Request failed", { status, headers: { "x-ratelimit-remaining": "100" } })]);
85+
await assert.rejects(fixture.request("POST", "/repos/example/repo/labels", { name: "Bug" }), new RegExp(`${status}: Request failed`));
86+
assert.equal(fixture.requests.length, 1);
87+
assert.deepEqual(fixture.waits, []);
88+
});
89+
}
90+
91+
test("network failures are not retried because a write may already have been applied", async (t) => {
92+
const fixture = setup(t, []);
93+
let attempts = 0;
94+
t.mock.method(globalThis, "fetch", async () => {
95+
attempts += 1;
96+
throw new Error("Connection lost");
97+
});
98+
await assert.rejects(fixture.request("POST", "/repos/example/repo/labels", { name: "Bug" }), /Connection lost/);
99+
assert.equal(attempts, 1);
100+
assert.deepEqual(fixture.waits, []);
101+
});

test/transfer-labels.test.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66
import test from "node:test";
77
import { promisify } from "node:util";
88
import { transferLabels } from "../scripts/transfer-labels.mjs";
9+
import { createGithubRequest } from "../scripts/lib/github-request.mjs";
910

1011
const label = (name, color = "abcdef", description = "") => ({ name, color, description });
1112

@@ -27,6 +28,7 @@ async function setup(t, {
2728
source = [label("Bug", "ff0000", "Source description"), label("New / 🚀", "123456", null)],
2829
target = [label("bug", "000000", "Keep this"), label("Extra")],
2930
failRequest = () => false,
31+
responseForRequest = () => null,
3032
sourceId = 1,
3133
targetId = 2,
3234
archived = false,
@@ -42,17 +44,22 @@ async function setup(t, {
4244
else process.env.GITHUB_STEP_SUMMARY = previousSummary;
4345
});
4446
const requests = [];
47+
const waits = [];
48+
let time = 1_700_000_000_000;
4549
t.mock.method(globalThis, "fetch", async (url, options) => {
4650
const parsed = new URL(url);
4751
const request = {
4852
method: options.method,
4953
path: parsed.pathname,
5054
query: parsed.searchParams,
5155
body: options.body ? JSON.parse(options.body) : undefined,
56+
time,
5257
};
5358
requests.push(request);
5459
assert.equal(parsed.origin, "https://api.github.com");
5560
assert.equal(options.headers.Authorization, "Bearer test-token");
61+
const customResponse = responseForRequest(request);
62+
if (customResponse) return customResponse;
5663
if (failRequest(request)) return new Response("Simulated failure", { status: 403 });
5764
if (request.method === "GET") {
5865
if (request.path === "/repos/example/source") {
@@ -73,11 +80,16 @@ async function setup(t, {
7380
});
7481
return {
7582
requests,
83+
waits,
7684
writes: () => requests.filter(({ method }) => method !== "GET"),
7785
summary: () => fs.readFile(summaryPath, "utf8"),
7886
run: (options = {}) => transferLabels({
7987
token: "test-token", organization: "example",
8088
sourceRepository: "source", targetRepository: "target", ...options,
89+
githubRequest: createGithubRequest("test-token", {
90+
now: () => time,
91+
sleep: async (milliseconds) => { waits.push(milliseconds); time += milliseconds; },
92+
}),
8193
}),
8294
};
8395
}
@@ -118,6 +130,7 @@ for (const overrideExisting of [false, true]) {
118130
const fixture = await setup(t);
119131
await fixture.run({ dryRun: true, overrideExisting });
120132
assert.deepEqual(fixture.writes(), []);
133+
assert.deepEqual(fixture.waits, []);
121134
const summary = await fixture.summary();
122135
assert.match(summary, /# Transfer-Labels Fake Changelog/);
123136
assert.match(summary, /\*\*Test Mode:\*\* True/);
@@ -139,6 +152,60 @@ test("reads every page from both repositories before transferring", async (t) =>
139152
assert.match(await fixture.summary(), /\*\*Source Labels:\*\* 101/);
140153
});
141154

155+
test("a 233-label transfer pauses on secondary limits and reports each copied label once", async (t) => {
156+
let throttled = false;
157+
const fixture = await setup(t, {
158+
source: Array.from({ length: 233 }, (_, index) => label(`Label ${index}`)),
159+
target: [],
160+
responseForRequest: ({ method, body }) => {
161+
if (method === "POST" && body.name === "Label 120" && !throttled) {
162+
throttled = true;
163+
return Response.json({ message: "You have exceeded a secondary rate limit." }, { status: 403 });
164+
}
165+
return null;
166+
},
167+
});
168+
const result = await fixture.run();
169+
assert.equal(result.createdLabels.length, 233);
170+
const writes = fixture.writes();
171+
assert.equal(writes.length, 234);
172+
assert.equal(new Set(writes.map(({ body }) => body.name)).size, 233);
173+
for (let index = 1; index < writes.length; index += 1) {
174+
assert.ok(writes[index].time - writes[index - 1].time >= 1000, "Every write must be paced");
175+
}
176+
assert.deepEqual(fixture.waits.filter((milliseconds) => milliseconds >= 60000), [60000]);
177+
const summary = await fixture.summary();
178+
assert.match(summary, /\*\*Created Labels:\*\* 233/);
179+
assert.doesNotMatch(summary, /## Workflow Failure/);
180+
assert.equal((summary.match(/Created `Label 120`/g) ?? []).length, 1);
181+
});
182+
183+
test("rerunning a partial 233-label transfer only creates the remaining labels", async (t) => {
184+
const source = Array.from({ length: 233 }, (_, index) => label(`Label ${index}`));
185+
const fixture = await setup(t, { source, target: source.slice(0, 150) });
186+
await fixture.run();
187+
assert.equal(fixture.writes().length, 83);
188+
assert.ok(fixture.writes().every(({ method, body }) => method === "POST" && Number(body.name.slice(6)) >= 150));
189+
assert.match(await fixture.summary(), /\*\*Created Labels:\*\* 83/);
190+
});
191+
192+
test("exhausted rate-limit retries preserve the partial changelog and prevent deletions", async (t) => {
193+
const fixture = await setup(t, {
194+
source: [label("First"), label("Blocked")],
195+
target: [label("Extra")],
196+
responseForRequest: ({ method, body }) => method === "POST" && body.name === "Blocked"
197+
? new Response("Secondary rate limit", { status: 403 }) : null,
198+
});
199+
await assert.rejects(fixture.run({ overrideExisting: true }), /after 5 retries/);
200+
assert.equal(fixture.writes().length, 7);
201+
assert.ok(fixture.writes().every(({ method }) => method === "POST"));
202+
const summary = await fixture.summary();
203+
assert.match(summary, /\*\*Created Labels:\*\* 1/);
204+
assert.match(summary, /\*\*Deleted Labels:\*\* 0/);
205+
assert.match(summary, /## Workflow Failure/);
206+
assert.doesNotMatch(summary, /Created `Blocked`/);
207+
});
208+
142209
test("override is a no-op when the receiving repository already matches", async (t) => {
143210
const fixture = await setup(t, { source: [label("Bug")], target: [label("Bug")] });
144211
await fixture.run({ overrideExisting: true });

0 commit comments

Comments
 (0)