Compare commits

..

4 Commits

Author SHA1 Message Date
4585801f32 v1.17.2
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-07 12:34:37 +00:00
3dc75f5cda fix(registry): improve HTTP fetch retry logging, backoff calculation, and token-cache warning 2026-02-07 12:34:37 +00:00
7591e0ed90 v1.17.1
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-07 12:29:43 +00:00
d2c2a4c4dd fix(registrycopy): add fetchWithRetry wrapper to apply timeouts, retries with exponential backoff, and token cache handling; use it for registry HTTP requests 2026-02-07 12:29:43 +00:00
4 changed files with 80 additions and 7 deletions

View File

@@ -1,5 +1,22 @@
# Changelog # Changelog
## 2026-02-07 - 1.17.2 - fix(registry)
improve HTTP fetch retry logging, backoff calculation, and token-cache warning
- Include HTTP method in logs and normalize method to uppercase for consistency
- Log retry attempts with method, URL and calculated exponential backoff delay
- Compute and reuse exponential backoff delay variable instead of inline calculation
- Log error when a 5xx response persists after all retry attempts and when fetch ultimately fails
- Add a warning log when clearing cached token after a 401 response
## 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) ## 2026-02-07 - 1.17.0 - feat(tsdocker)
add Dockerfile filtering, optional skip-build flow, and fallback Docker config credential loading add Dockerfile filtering, optional skip-build flow, and fallback Docker config credential loading

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tsdocker", "name": "@git.zone/tsdocker",
"version": "1.17.0", "version": "1.17.2",
"private": false, "private": false,
"description": "develop npm modules cross platform with docker", "description": "develop npm modules cross platform with docker",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsdocker', name: '@git.zone/tsdocker',
version: '1.17.0', version: '1.17.2',
description: 'develop npm modules cross platform with docker' description: 'develop npm modules cross platform with docker'
} }

View File

@@ -20,6 +20,53 @@ interface ITokenCache {
export class RegistryCopy { export class RegistryCopy {
private tokenCache: ITokenCache = {}; 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<Response> {
const method = (options.method || 'GET').toUpperCase();
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (attempt > 1) {
logger.log('info', `Retry ${attempt}/${maxRetries} for ${method} ${url}`);
}
const resp = await fetch(url, {
...options,
signal: AbortSignal.timeout(timeoutMs),
});
// Retry on 5xx server errors (but not 4xx)
if (resp.status >= 500 && attempt < maxRetries) {
const delay = 1000 * Math.pow(2, attempt - 1);
logger.log('warn', `${method} ${url} returned ${resp.status}, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})...`);
await new Promise(r => setTimeout(r, delay));
continue;
}
if (resp.status >= 500) {
logger.log('error', `${method} ${url} returned ${resp.status} after ${maxRetries} attempts, giving up`);
}
return resp;
} catch (err) {
lastError = err as Error;
if (attempt < maxRetries) {
const delay = 1000 * Math.pow(2, attempt - 1);
logger.log('warn', `${method} ${url} failed (attempt ${attempt}/${maxRetries}): ${lastError.message}, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
} else {
logger.log('error', `${method} ${url} failed after ${maxRetries} attempts: ${lastError.message}`);
}
}
}
throw lastError!;
}
/** /**
* Reads Docker credentials from ~/.docker/config.json for a given registry. * Reads Docker credentials from ~/.docker/config.json for a given registry.
* Supports base64-encoded "auth" field in the config. * Supports base64-encoded "auth" field in the config.
@@ -109,7 +156,7 @@ export class RegistryCopy {
} }
try { 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 if (checkResp.ok) return null; // No auth needed
const wwwAuth = checkResp.headers.get('www-authenticate') || ''; const wwwAuth = checkResp.headers.get('www-authenticate') || '';
@@ -131,7 +178,7 @@ export class RegistryCopy {
headers['Authorization'] = 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'); 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) { if (!tokenResp.ok) {
const body = await tokenResp.text(); const body = await tokenResp.text();
throw new Error(`Token request failed (${tokenResp.status}): ${body}`); throw new Error(`Token request failed (${tokenResp.status}): ${body}`);
@@ -189,7 +236,16 @@ export class RegistryCopy {
fetchOptions.duplex = 'half'; // Required for streaming body in Node 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}`}`;
logger.log('warn', `Got 401 for ${registry}${path} — clearing cached token for ${cacheKey}`);
delete this.tokenCache[cacheKey];
}
return resp;
} }
/** /**
@@ -320,11 +376,11 @@ export class RegistryCopy {
putHeaders['Authorization'] = `Bearer ${token}`; putHeaders['Authorization'] = `Bearer ${token}`;
} }
const putResp = await fetch(putUrl, { const putResp = await this.fetchWithRetry(putUrl, {
method: 'PUT', method: 'PUT',
headers: putHeaders, headers: putHeaders,
body: blobData, body: blobData,
}); }, 300_000);
if (!putResp.ok) { if (!putResp.ok) {
const body = await putResp.text(); const body = await putResp.text();