From 82bb27f41ce6a2e833652c7eac45d76ed1ec771c Mon Sep 17 00:00:00 2001 From: phusi319 Date: Fri, 27 Mar 2026 14:13:35 +0200 Subject: [PATCH 1/3] fix: add timeout to registry URL validation to prevent CLI hang (#2027) Add AbortController with 10s timeout to registryValidation() fetch call. When --registry-url points to an unreachable host, the CLI now fails fast with a clear error instead of hanging indefinitely. Changes: - Use HEAD method instead of GET for lightweight validation - Add AbortController with 10s timeout - Differentiate timeout vs network vs auth errors in messages - Clear timer in finally block to prevent resource leaks - Add unit tests for URL parser and timeout behavior --- src/utils/generate/registry.ts | 23 +++++++++++++-- test/unit/utils/registry.test.ts | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 test/unit/utils/registry.test.ts diff --git a/src/utils/generate/registry.ts b/src/utils/generate/registry.ts index 16fdda2e5..112b121b8 100644 --- a/src/utils/generate/registry.ts +++ b/src/utils/generate/registry.ts @@ -1,3 +1,5 @@ +const REGISTRY_TIMEOUT_MS = 10_000; + export function registryURLParser(input?: string) { if (!input) { return; } const isURL = /^https?:/; @@ -8,12 +10,27 @@ export function registryURLParser(input?: string) { export async function registryValidation(registryUrl?: string, registryAuth?: string, registryToken?: string) { if (!registryUrl) { return; } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); + try { - const response = await fetch(registryUrl as string); + const response = await fetch(registryUrl as string, { + method: 'HEAD', + signal: controller.signal, + }); if (response.status === 401 && !registryAuth && !registryToken) { throw new Error('You Need to pass either registryAuth in username:password encoded in Base64 or need to pass registryToken'); } - } catch { - throw new Error(`Can't fetch registryURL: ${registryUrl}`); + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Registry URL validation timed out after ${REGISTRY_TIMEOUT_MS / 1000}s: ${registryUrl}`); + } + if (error instanceof Error && error.message.includes('registryAuth')) { + throw error; + } + throw new Error(`Unable to reach registry URL: ${registryUrl}`); + } finally { + clearTimeout(timer); } } diff --git a/test/unit/utils/registry.test.ts b/test/unit/utils/registry.test.ts new file mode 100644 index 000000000..62ac343b8 --- /dev/null +++ b/test/unit/utils/registry.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { registryURLParser, registryValidation } from '../../../src/utils/generate/registry'; + +describe('registryURLParser()', () => { + it('should return undefined for empty input', () => { + expect(registryURLParser(undefined)).to.be.undefined; + expect(registryURLParser('')).to.be.undefined; + }); + + it('should accept valid http URLs', () => { + expect(() => registryURLParser('https://registry.npmjs.org')).to.not.throw(); + expect(() => registryURLParser('http://localhost:4873')).to.not.throw(); + }); + + it('should reject non-http URLs', () => { + expect(() => registryURLParser('ftp://registry.example.com')).to.throw('Invalid --registry-url'); + expect(() => registryURLParser('not-a-url')).to.throw('Invalid --registry-url'); + }); +}); + +describe('registryValidation()', () => { + it('should return undefined when no URL provided', async () => { + const result = await registryValidation(undefined); + expect(result).to.be.undefined; + }); + + it('should fail fast for unreachable URLs instead of hanging', async () => { + const start = Date.now(); + try { + // 10.255.255.1 is a non-routable IP that will trigger the timeout + await registryValidation('http://10.255.255.1'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const elapsed = Date.now() - start; + expect(elapsed).to.be.lessThan(15_000); // Must resolve within 15s (10s timeout + margin) + expect(error).to.be.instanceOf(Error); + const msg = (error as Error).message; + expect(msg).to.satisfy( + (m: string) => m.includes('timed out') || m.includes('Unable to reach'), + `Expected timeout or unreachable error, got: ${msg}` + ); + } + }).timeout(20_000); + + it('should throw auth error for 401 without credentials', async () => { + // This test requires a reachable URL that returns 401 + // We skip if no test server is available — the logic is unit-testable via the error path + }); +}); From 6bba54c82ab2d4bd24e5cf819efa2e4779cffd41 Mon Sep 17 00:00:00 2001 From: Phu Si On Date: Fri, 27 Mar 2026 15:50:34 +0200 Subject: [PATCH 2/3] chore: add changeset for registry timeout fix --- .changeset/fix-registry-timeout.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-registry-timeout.md diff --git a/.changeset/fix-registry-timeout.md b/.changeset/fix-registry-timeout.md new file mode 100644 index 000000000..91e93e180 --- /dev/null +++ b/.changeset/fix-registry-timeout.md @@ -0,0 +1,5 @@ +--- +"@asyncapi/cli": patch +--- + +Add timeout to registry URL validation to prevent CLI hang when registry is unreachable. From 89803aad697b46610950c497df0343f9d5924e71 Mon Sep 17 00:00:00 2001 From: Phu Si On Date: Sat, 28 Mar 2026 15:52:44 +0200 Subject: [PATCH 3/3] test: improve registry validation tests with proper mocking - Replace real network call (10.255.255.1) with stubbed fetch to avoid flaky tests - Implement actual 401 auth error test instead of empty placeholder - Add test for generic network failure scenario - Use RegistryAuthError class to avoid brittle string matching in error handling - Refactor registryValidation to check auth after successful fetch (cleaner flow) --- src/utils/generate/registry.ts | 22 +++++++++---- test/unit/utils/registry.test.ts | 55 +++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/utils/generate/registry.ts b/src/utils/generate/registry.ts index 112b121b8..7f0ccb456 100644 --- a/src/utils/generate/registry.ts +++ b/src/utils/generate/registry.ts @@ -1,5 +1,13 @@ const REGISTRY_TIMEOUT_MS = 10_000; +/** Custom error class for registry authentication errors */ +class RegistryAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryAuthError'; + } +} + export function registryURLParser(input?: string) { if (!input) { return; } const isURL = /^https?:/; @@ -14,23 +22,23 @@ export async function registryValidation(registryUrl?: string, registryAuth?: st const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); + let response: Response; try { - const response = await fetch(registryUrl as string, { + response = await fetch(registryUrl as string, { method: 'HEAD', signal: controller.signal, }); - if (response.status === 401 && !registryAuth && !registryToken) { - throw new Error('You Need to pass either registryAuth in username:password encoded in Base64 or need to pass registryToken'); - } } catch (error: unknown) { + clearTimeout(timer); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Registry URL validation timed out after ${REGISTRY_TIMEOUT_MS / 1000}s: ${registryUrl}`); } - if (error instanceof Error && error.message.includes('registryAuth')) { - throw error; - } throw new Error(`Unable to reach registry URL: ${registryUrl}`); } finally { clearTimeout(timer); } + + if (response.status === 401 && !registryAuth && !registryToken) { + throw new RegistryAuthError('You Need to pass either registryAuth in username:password encoded in Base64 or need to pass registryToken'); + } } diff --git a/test/unit/utils/registry.test.ts b/test/unit/utils/registry.test.ts index 62ac343b8..e3d8b5b24 100644 --- a/test/unit/utils/registry.test.ts +++ b/test/unit/utils/registry.test.ts @@ -25,25 +25,56 @@ describe('registryValidation()', () => { }); it('should fail fast for unreachable URLs instead of hanging', async () => { - const start = Date.now(); + // Stub fetch to simulate a timeout scenario without real network calls + const originalFetch = global.fetch; + global.fetch = () => new Promise((_, reject) => { + // Simulate abort after timeout + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + setTimeout(() => reject(abortError), 50); + }); + try { - // 10.255.255.1 is a non-routable IP that will trigger the timeout - await registryValidation('http://10.255.255.1'); + await registryValidation('http://example.com'); expect.fail('Should have thrown'); } catch (error: unknown) { - const elapsed = Date.now() - start; - expect(elapsed).to.be.lessThan(15_000); // Must resolve within 15s (10s timeout + margin) expect(error).to.be.instanceOf(Error); const msg = (error as Error).message; - expect(msg).to.satisfy( - (m: string) => m.includes('timed out') || m.includes('Unable to reach'), - `Expected timeout or unreachable error, got: ${msg}` - ); + expect(msg).to.include('timed out'); + } finally { + global.fetch = originalFetch; } - }).timeout(20_000); + }); it('should throw auth error for 401 without credentials', async () => { - // This test requires a reachable URL that returns 401 - // We skip if no test server is available — the logic is unit-testable via the error path + const originalFetch = global.fetch; + global.fetch = () => Promise.resolve(new Response(null, { status: 401 })); + + try { + await registryValidation('http://example.com'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(Error); + const msg = (error as Error).message; + expect(msg).to.include('registryAuth'); + } finally { + global.fetch = originalFetch; + } + }); + + it('should throw unreachable error for network failures', async () => { + const originalFetch = global.fetch; + global.fetch = () => Promise.reject(new Error('Network error')); + + try { + await registryValidation('http://example.com'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(Error); + const msg = (error as Error).message; + expect(msg).to.include('Unable to reach'); + } finally { + global.fetch = originalFetch; + } }); });