From d2c2a4c4dd162de746754fb3d6cd449aef9a13f8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 7 Feb 2026 12:29:43 +0000 Subject: [PATCH] fix(registrycopy): add fetchWithRetry wrapper to apply timeouts, retries with exponential backoff, and token cache handling; use it for registry HTTP requests --- changelog.md | 8 ++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.registrycopy.ts | 55 ++++++++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/changelog.md b/changelog.md index 07befe3..2cd1188 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-02-07 - 1.17.1 - fix(registrycopy) +add fetchWithRetry wrapper to apply timeouts, retries with exponential backoff, and token cache handling; use it for registry HTTP requests + +- Introduces fetchWithRetry(url, options, timeoutMs, maxRetries) to wrap fetch with AbortSignal timeout, exponential backoff retries, and retry behavior only for network errors and 5xx responses +- Replaces direct fetch calls for registry /v2 checks, token requests, and blob uploads with fetchWithRetry (30s for auth/token checks, 300s for blob operations) +- Clears token cache entry when a 401 response is received so the next attempt re-authenticates +- Adds logging on retry attempts and backoff delays to improve robustness and observability + ## 2026-02-07 - 1.17.0 - feat(tsdocker) add Dockerfile filtering, optional skip-build flow, and fallback Docker config credential loading diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3f76c31..7edf473 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsdocker', - version: '1.17.0', + version: '1.17.1', description: 'develop npm modules cross platform with docker' } diff --git a/ts/classes.registrycopy.ts b/ts/classes.registrycopy.ts index a35f91c..b37f08a 100644 --- a/ts/classes.registrycopy.ts +++ b/ts/classes.registrycopy.ts @@ -20,6 +20,43 @@ interface ITokenCache { export class RegistryCopy { private tokenCache: ITokenCache = {}; + /** + * Wraps fetch() with timeout (via AbortSignal) and retry with exponential backoff. + * Retries on network errors and 5xx; does NOT retry on 4xx client errors. + * On 401, clears the token cache entry so the next attempt re-authenticates. + */ + private async fetchWithRetry( + url: string, + options: RequestInit & { duplex?: string }, + timeoutMs: number = 300_000, + maxRetries: number = 3, + ): Promise { + let lastError: Error | null = null; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const resp = await fetch(url, { + ...options, + signal: AbortSignal.timeout(timeoutMs), + }); + // Retry on 5xx server errors (but not 4xx) + if (resp.status >= 500 && attempt < maxRetries) { + logger.log('warn', `Request to ${url} returned ${resp.status}, retrying (${attempt}/${maxRetries})...`); + await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1))); + continue; + } + return resp; + } catch (err) { + lastError = err as Error; + if (attempt < maxRetries) { + const delay = 1000 * Math.pow(2, attempt - 1); + logger.log('warn', `fetch failed (attempt ${attempt}/${maxRetries}): ${lastError.message}, retrying in ${delay}ms...`); + await new Promise(r => setTimeout(r, delay)); + } + } + } + throw lastError!; + } + /** * Reads Docker credentials from ~/.docker/config.json for a given registry. * Supports base64-encoded "auth" field in the config. @@ -109,7 +146,7 @@ export class RegistryCopy { } try { - const checkResp = await fetch(`${apiBase}/v2/`, { method: 'GET' }); + const checkResp = await this.fetchWithRetry(`${apiBase}/v2/`, { method: 'GET' }, 30_000); if (checkResp.ok) return null; // No auth needed const wwwAuth = checkResp.headers.get('www-authenticate') || ''; @@ -131,7 +168,7 @@ export class RegistryCopy { headers['Authorization'] = 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'); } - const tokenResp = await fetch(tokenUrl.toString(), { headers }); + const tokenResp = await this.fetchWithRetry(tokenUrl.toString(), { headers }, 30_000); if (!tokenResp.ok) { const body = await tokenResp.text(); throw new Error(`Token request failed (${tokenResp.status}): ${body}`); @@ -189,7 +226,15 @@ export class RegistryCopy { fetchOptions.duplex = 'half'; // Required for streaming body in Node } - return fetch(url, fetchOptions); + const resp = await this.fetchWithRetry(url, fetchOptions, 300_000); + + // Token expired — clear cache so next call re-authenticates + if (resp.status === 401 && token) { + const cacheKey = `${registry}/${`repository:${repo}:${actions}`}`; + delete this.tokenCache[cacheKey]; + } + + return resp; } /** @@ -320,11 +365,11 @@ export class RegistryCopy { putHeaders['Authorization'] = `Bearer ${token}`; } - const putResp = await fetch(putUrl, { + const putResp = await this.fetchWithRetry(putUrl, { method: 'PUT', headers: putHeaders, body: blobData, - }); + }, 300_000); if (!putResp.ok) { const body = await putResp.text();