Skip to content

Commit ffd1b57

Browse files
committed
✨ feat(registries): 429 retry with Retry-After + per-host token bucket + GH-token fallback
- New withRetry helper (app/registries/http-retry.ts) wraps registry HTTP calls. Retries 429/503 only, honors Retry-After (seconds and HTTP-date forms), falls back to exponential backoff (1s/2s/4s, cap 60s), max 3 retries. Non-retryable statuses and non-Axios errors throw immediately. - New per-host token bucket (app/registries/token-bucket.ts) prevents the watcher from self-inflicting 429s by bursting too fast within a cron cycle. Conservative defaults per host: GHCR/Hub at 2/s burst-5, api.github.com at 1/s burst-3, other hosts 5/s burst-10. - callRegistry() now acquires a token bucket slot before calling axios, then wraps the call in withRetry. The full AxiosResponse is captured in a closure so resolveWithFullResponse callers still get response headers. - GithubProvider falls back to the first configured GHCR PAT (ghcr-token-fallback.ts) when DD_RELEASE_NOTES_GITHUB_TOKEN is unset. GitHub PATs work for both ghcr.io and api.github.com, so users who configured GHCR auth get release notes for free. - GithubProvider also wrapped in withRetry so 429s from api.github.com are absorbed instead of cascading into spurious 'rate-limited' warnings. Fixes: GH-342
1 parent 069274f commit ffd1b57

10 files changed

Lines changed: 868 additions & 17 deletions

File tree

app/registries/Registry.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ vi.mock('../prometheus/registry', () => ({
88
}),
99
}));
1010

11+
// withRetry: pass-through by default (calls the request fn once and returns its data).
12+
// acquireToken: no-op (token bucket has no effect on unit tests).
13+
vi.mock('./http-retry.js', () => ({
14+
withRetry: vi.fn(async (requestFn) => {
15+
const response = await requestFn();
16+
return response.data;
17+
}),
18+
}));
19+
vi.mock('./token-bucket.js', () => ({
20+
acquireToken: vi.fn(() => Promise.resolve()),
21+
getBucketForUrl: vi.fn(() => ({ key: 'mock-host', ratePerSec: 10, burst: 10 })),
22+
}));
23+
1124
import Registry from './Registry.js';
1225

1326
// --- Factory helpers (not used inside vi.mock, safe to define here) ---
@@ -1204,4 +1217,74 @@ describe('callRegistry', () => {
12041217
});
12051218
expect(result).toBe(mockResponse);
12061219
});
1220+
1221+
test('acquires a token bucket token before each request', async () => {
1222+
const { default: axios } = await import('axios');
1223+
const { acquireToken } = await import('./token-bucket.js');
1224+
vi.clearAllMocks();
1225+
axios.mockResolvedValue({ data: 'ok', headers: {} });
1226+
const registryMocked = createMockedRegistry();
1227+
await registryMocked.callRegistry({
1228+
image: {},
1229+
url: 'https://ghcr.io/v2/img/tags/list',
1230+
method: 'get',
1231+
});
1232+
expect(acquireToken).toHaveBeenCalledTimes(1);
1233+
expect(acquireToken).toHaveBeenCalledWith(expect.objectContaining({ key: expect.any(String) }));
1234+
});
1235+
1236+
test('delegates 429 retry to withRetry and rethrows after exhaustion', async () => {
1237+
const { withRetry } = await import('./http-retry.js');
1238+
// Override withRetry to simulate exhausted retries throwing the 429
1239+
const err429 = new Error('Request failed with status code 429');
1240+
(err429 as any).response = { status: 429, headers: {} };
1241+
(withRetry as ReturnType<typeof vi.fn>).mockRejectedValueOnce(err429);
1242+
1243+
const registryMocked = createMockedRegistry();
1244+
registryMocked.type = 'hub';
1245+
registryMocked.name = 'test';
1246+
1247+
await expect(
1248+
registryMocked.callRegistry({ image: {}, url: 'url', method: 'get' }),
1249+
).rejects.toThrow('status code 429');
1250+
});
1251+
1252+
test('delegates 503 retry to withRetry and rethrows after exhaustion', async () => {
1253+
const { withRetry } = await import('./http-retry.js');
1254+
const err503 = new Error('Request failed with status code 503');
1255+
(err503 as any).response = { status: 503, headers: {} };
1256+
(withRetry as ReturnType<typeof vi.fn>).mockRejectedValueOnce(err503);
1257+
1258+
const registryMocked = createMockedRegistry();
1259+
registryMocked.type = 'hub';
1260+
registryMocked.name = 'test';
1261+
1262+
await expect(
1263+
registryMocked.callRegistry({ image: {}, url: 'url', method: 'get' }),
1264+
).rejects.toThrow('status code 503');
1265+
});
1266+
1267+
test('passes logger and requestLabel to withRetry', async () => {
1268+
const { default: axios } = await import('axios');
1269+
const { withRetry } = await import('./http-retry.js');
1270+
axios.mockResolvedValue({ data: 'payload', headers: {} });
1271+
1272+
const registryMocked = createMockedRegistry();
1273+
registryMocked.type = 'hub';
1274+
registryMocked.name = 'myname';
1275+
1276+
await registryMocked.callRegistry({
1277+
image: {},
1278+
url: 'https://registry.io/v2/img/tags/list',
1279+
method: 'get',
1280+
});
1281+
1282+
expect(withRetry).toHaveBeenCalledWith(
1283+
expect.any(Function),
1284+
expect.objectContaining({
1285+
logger: expect.objectContaining({ debug: expect.any(Function) }),
1286+
requestLabel: expect.stringContaining('https://registry.io/v2/img/tags/list'),
1287+
}),
1288+
);
1289+
});
12071290
});

app/registries/Registry.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { getSummaryTags } from '../prometheus/registry.js';
66
import Component, { type ComponentConfiguration } from '../registry/Component.js';
77
import { getErrorMessage } from '../util/error.js';
88
import { getRegistryRequestTimeoutMs } from './configuration.js';
9+
import { withRetry } from './http-retry.js';
10+
import { acquireToken, getBucketForUrl } from './token-bucket.js';
911

1012
interface RegistryManifest {
1113
digest?: string;
@@ -465,10 +467,34 @@ class Registry<
465467
};
466468

467469
try {
468-
const response = await axios<T>(axiosOptionsWithConnectionReuse);
470+
// Rate-limit ourselves before hitting the registry
471+
await acquireToken(getBucketForUrl(url));
472+
473+
// Capture the full axios response so we can return headers when needed.
474+
let lastAxiosResponse: AxiosResponse<T> | undefined;
475+
476+
await withRetry<T>(
477+
() =>
478+
axios<T>(axiosOptionsWithConnectionReuse).then((r) => {
479+
lastAxiosResponse = r;
480+
return {
481+
status: r.status,
482+
headers: r.headers as Record<string, string | undefined>,
483+
data: r.data,
484+
};
485+
}),
486+
{
487+
logger: this.log,
488+
requestLabel: `${this.getId()} ${method} ${url}`,
489+
},
490+
);
491+
469492
const end = Date.now();
470493
getSummaryTags()?.observe({ type: this.type, name: this.name }, (end - start) / 1000);
471-
return resolveWithFullResponse ? response : response.data;
494+
// lastAxiosResponse is always set when withRetry resolves
495+
return resolveWithFullResponse
496+
? (lastAxiosResponse as AxiosResponse<T>)
497+
: lastAxiosResponse!.data;
472498
} catch (error) {
473499
const end = Date.now();
474500
getSummaryTags()?.observe({ type: this.type, name: this.name }, (end - start) / 1000);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Returns the PAT token from the first credentialed GHCR registry instance,
3+
* or undefined if none is configured.
4+
*
5+
* GHCR tokens (github.com PATs) work for both the container registry and the
6+
* GitHub REST API, so the release-notes provider can reuse them when no
7+
* dedicated DD_RELEASE_NOTES_GITHUB_TOKEN is set.
8+
*/
9+
10+
import { getState } from '../registry/index.js';
11+
12+
export function getGhcrTokenFallback(): string | undefined {
13+
const registryState = getState().registry;
14+
for (const instance of Object.values(registryState)) {
15+
// Duck-type: we only need the provider type and the token field.
16+
const cfg = (instance as { type?: string; configuration?: { token?: string } }).configuration;
17+
const type = (instance as { type?: string }).type;
18+
if (type === 'ghcr' && typeof cfg?.token === 'string' && cfg.token.length > 0) {
19+
return cfg.token;
20+
}
21+
}
22+
return undefined;
23+
}

0 commit comments

Comments
 (0)