From 62c5e17eecb07718e7f53d74dc108cae952650fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:05:35 +0000 Subject: [PATCH] fix: retry transient failures in container metadata fetch - Add `withRetry` helper to docker-registry.js that retries up to 2 times on transient errors (timeout, ECONNRESET, ECONNREFUSED, ETIMEDOUT, ENOTFOUND, HTTP 5xx) with linear backoff (2s, 4s) - Wrap `getImageConfig` with `withRetry` so registry timeouts no longer fail the metadata endpoint on the first attempt - Improve frontend error message to hint the user can click "Fetch metadata" to retry manually if all retries are exhausted Closes #385 --- .../pages/containers/ContainerFormPage.tsx | 3 +- create-a-container/utils/docker-registry.js | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 87ec7ebe..548d0123 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -229,7 +229,8 @@ export function ContainerFormPage() { } setMetadataMsg(`Loaded metadata: ${added} port(s) discovered.`); }, - onError: (err: ApiError) => setMetadataMsg(err.message), + onError: (err: ApiError) => + setMetadataMsg(`${err.message} — Use "Fetch metadata" to try again.`), }); // Tracks the last image we auto-fetched so blurring the custom-image input diff --git a/create-a-container/utils/docker-registry.js b/create-a-container/utils/docker-registry.js index ef61bfe3..ef110386 100644 --- a/create-a-container/utils/docker-registry.js +++ b/create-a-container/utils/docker-registry.js @@ -153,6 +153,35 @@ async function authenticatedFetchJson(url, headers = {}) { } } +/** + * Retry an async operation on transient failures (timeout, network error, 5xx). + * @param {Function} fn - Async function to execute + * @param {number} maxRetries - Number of additional attempts after the first failure + * @param {number} retryDelay - Base delay in ms between attempts (doubles each retry) + * @returns {Promise} Resolved value of fn + */ +async function withRetry(fn, maxRetries = 2, retryDelay = 2000) { + let lastError; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt === maxRetries) break; + const isTransient = + err.message.includes('timeout') || + err.message.includes('ECONNRESET') || + err.message.includes('ECONNREFUSED') || + err.message.includes('ETIMEDOUT') || + err.message.includes('ENOTFOUND') || + /HTTP 5\d\d/.test(err.message); + if (!isTransient) throw err; + await new Promise((resolve) => setTimeout(resolve, retryDelay * (attempt + 1))); + } + } + throw lastError; +} + /** * Check if a template is a Docker image reference (contains '/') * @param {string} template - The template string @@ -242,6 +271,10 @@ async function getImageDigest(registry, repo, tag) { * @returns {Promise} Image config object */ async function getImageConfig(registry, repo, tag) { + return withRetry(() => _getImageConfig(registry, repo, tag)); +} + +async function _getImageConfig(registry, repo, tag) { const registryHost = registry === 'docker.io' ? 'registry-1.docker.io' : registry; // First, fetch the manifest to get the config digest