Skip to content

Commit a7ca35d

Browse files
committed
Retry transient GitHub failures before native benchmarks
1 parent 3ff933d commit a7ca35d

4 files changed

Lines changed: 130 additions & 3 deletions

File tree

‎scripts/Features/BenchmarkComparisons/isolated-github-rate.mjs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { randomInt } from 'node:crypto';
12
import { GH, requireGitHub } from './isolated-github-contract.mjs';
23

4+
const transientStatuses = Object.freeze([500, 502, 503, 504]);
5+
const transientInitialDelayMs = 5000;
6+
const transientMaximumJitterMs = 1000;
7+
38
function retryAfter(value, nowMs) {
49
if (/^\d+$/.test(value)) {
510
const seconds = Number(value);
@@ -13,18 +18,23 @@ function retryAfter(value, nowMs) {
1318
}
1419

1520
export function retryDelay(response, nowMs, waitedMs, repeats) {
16-
requireGitHub([403, 429].includes(response?.status) && Number.isSafeInteger(nowMs)
21+
const transient = transientStatuses.includes(response?.status);
22+
requireGitHub(([403, 429].includes(response?.status) || transient) && Number.isSafeInteger(nowMs)
1723
&& Number.isSafeInteger(waitedMs) && waitedMs >= 0 && Number.isSafeInteger(repeats) && repeats >= 0 && repeats < GH.rateRepeats);
1824
const headers = response.headers;
25+
requireGitHub(headers !== null && typeof headers === 'object' && !Array.isArray(headers));
1926
const waits = [];
2027
if (Object.hasOwn(headers, 'retry-after')) waits.push(retryAfter(headers['retry-after'], nowMs));
2128
if (headers['x-ratelimit-remaining'] === '0') {
2229
const reset = headers['x-ratelimit-reset'];
2330
requireGitHub(typeof reset === 'string' && /^\d+$/.test(reset) && Number.isSafeInteger(Number(reset)) && Number(reset) > 0);
2431
waits.push(Math.max(0, Number(reset) * 1000 - nowMs));
2532
}
26-
requireGitHub(waits.length > 0);
27-
const delay = Math.max(1000, Math.ceil(Math.max(...waits) / 1000) * 1000 + 1000);
33+
requireGitHub(transient || waits.length > 0);
34+
const providerDelay = waits.length > 0 ? Math.max(1000, Math.ceil(Math.max(...waits) / 1000) * 1000 + 1000) : 0;
35+
const transientDelay = transient
36+
? transientInitialDelayMs * 2 ** repeats + randomInt(transientMaximumJitterMs + 1) : 0;
37+
const delay = Math.max(providerDelay, transientDelay);
2838
requireGitHub(Number.isSafeInteger(delay) && waitedMs + delay <= GH.rateWaitMs);
2939
return delay;
3040
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
HTTP/2.0 503 Service Unavailable
2+
Access-Control-Allow-Origin: *;
3+
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Used, X-RateLimit-Resource, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, Deprecation, Sunset
4+
Cache-Control: no-cache
5+
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'
6+
Content-Type: application/json; charset=utf-8
7+
Date: Sat, 03 Oct 2026 21:47:29 GMT
8+
Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
9+
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
10+
X-Content-Type-Options: nosniff
11+
X-Frame-Options: deny
12+
X-Github-Edge-Region: iad
13+
X-Github-Media-Type: github.v3; format=json
14+
X-Github-Request-Id: 2881:FA66A:29D80BD:8160003:6AC177B2
15+
X-Xss-Protection: 0
16+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace KeyLoad.UnitTests.Features.BenchmarkComparisons;
2+
3+
internal static class IsolatedGitHubTransientProgram
4+
{
5+
internal const string Source = """
6+
import assert from 'node:assert/strict';
7+
import { readFile } from 'node:fs/promises';
8+
import path from 'node:path';
9+
import { pathToFileURL } from 'node:url';
10+
const [modulePath, input, originalPath] = process.argv.slice(1);
11+
const api = await import(pathToFileURL(modulePath));
12+
const parser = await import(pathToFileURL(path.join(path.dirname(modulePath), 'isolated-github-headers.mjs')));
13+
const original = await readFile(originalPath);
14+
const now = Date.parse('2026-10-03T21:47:29Z');
15+
const accepted = new Set(['original', 'internal-error', 'bad-gateway', 'gateway-timeout',
16+
'second-retry', 'third-retry', 'retry-after', 'retry-date', 'provider-limits']).has(input);
17+
try {
18+
let response = parser.parseResponseHeaders(original);
19+
assert.equal(response.status, 503);
20+
let repeat = input === 'second-retry' ? 1 : input === 'third-retry' ? 2 : 0;
21+
let waited = 0;
22+
let expected = [5000 * 2 ** repeat, 5000 * 2 ** repeat + 1000];
23+
const statuses = { 'internal-error': 500, 'bad-gateway': 502, 'gateway-timeout': 504,
24+
unauthorized: 401, forbidden: 403, 'not-found': 404, 'unsupported-status': 408,
25+
'successful-response': 200 };
26+
if (Object.hasOwn(statuses, input)) response.status = statuses[input];
27+
if (input === 'retry-after') {
28+
response.headers['retry-after'] = '15'; expected = [16000, 16000];
29+
}
30+
if (input === 'retry-date') {
31+
response.headers['retry-after'] = 'Sat, 03 Oct 2026 21:47:41 GMT'; expected = [13000, 13000];
32+
}
33+
if (input === 'provider-limits') {
34+
response.headers['retry-after'] = '90';
35+
response.headers['x-ratelimit-remaining'] = '0';
36+
response.headers['x-ratelimit-reset'] = String(now / 1000 + 60); expected = [91000, 91000];
37+
}
38+
if (input === 'invalid-retry') response.headers['retry-after'] = 'invalid';
39+
if (input === 'invalid-reset') {
40+
response.headers['x-ratelimit-remaining'] = '0'; response.headers['x-ratelimit-reset'] = 'invalid';
41+
}
42+
if (input === 'duplicate-header') response = parser.parseResponseHeaders(Buffer.from(
43+
'HTTP/2.0 503 Service Unavailable\nRetry-After: 15\r\nRetry-After: 20\r\n\r\n'));
44+
if (input === 'missing-headers') response.headers = null;
45+
if (input === 'array-headers') response.headers = [];
46+
if (input === 'wait-budget') waited = 3700000 - 4999;
47+
if (input === 'repeat-budget') repeat = 3;
48+
if (input === 'negative-wait') waited = -1;
49+
if (input === 'negative-repeat') repeat = -1;
50+
if (input === 'fractional-repeat') repeat = 0.5;
51+
const delay = api.retryDelay(response, now, waited, repeat);
52+
if (accepted) {
53+
assert.ok(delay >= expected[0] && delay <= expected[1]);
54+
assert.ok(waited + delay <= 3700000);
55+
}
56+
process.stdout.write('accepted\n');
57+
} catch { process.stdout.write('rejected\n'); process.exitCode = 1; }
58+
""";
59+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace KeyLoad.UnitTests.Features.BenchmarkComparisons;
2+
3+
internal sealed class IsolatedGitHubTransientTests
4+
{
5+
[Test]
6+
[Arguments("original", true)]
7+
[Arguments("internal-error", true)]
8+
[Arguments("bad-gateway", true)]
9+
[Arguments("gateway-timeout", true)]
10+
[Arguments("second-retry", true)]
11+
[Arguments("third-retry", true)]
12+
[Arguments("retry-after", true)]
13+
[Arguments("retry-date", true)]
14+
[Arguments("provider-limits", true)]
15+
[Arguments("unauthorized", false)]
16+
[Arguments("forbidden", false)]
17+
[Arguments("not-found", false)]
18+
[Arguments("unsupported-status", false)]
19+
[Arguments("successful-response", false)]
20+
[Arguments("invalid-retry", false)]
21+
[Arguments("invalid-reset", false)]
22+
[Arguments("duplicate-header", false)]
23+
[Arguments("missing-headers", false)]
24+
[Arguments("array-headers", false)]
25+
[Arguments("wait-budget", false)]
26+
[Arguments("repeat-budget", false)]
27+
[Arguments("negative-wait", false)]
28+
[Arguments("negative-repeat", false)]
29+
[Arguments("fractional-repeat", false)]
30+
public async Task AcBcFail005RetriesOriginalTransientGetFailuresWithinBudgets(string input, bool accepted)
31+
{
32+
var module = IsolatedAggregateNodeProcess.Module("isolated-github-rate.mjs");
33+
var original = Path.Combine(IsolatedAggregateNodeProcess.RepositoryRoot(), "tests", "KeyLoad.UnitTests",
34+
"Features", "BenchmarkComparisons", "IsolatedGitHubTransientOriginal.headers");
35+
var result = await IsolatedAggregateNodeProcess.RunAsync(
36+
["--input-type=module", "-e", IsolatedGitHubTransientProgram.Source, module, input, original],
37+
TestContext.Current!.Execution.CancellationToken);
38+
await Assert.That(result.ExitCode).IsEqualTo(accepted ? 0 : 1);
39+
await Assert.That(result.Output.Trim()).IsEqualTo(accepted ? "accepted" : "rejected");
40+
await Assert.That(result.Error).IsEmpty();
41+
}
42+
}

0 commit comments

Comments
 (0)