Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-registry-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@asyncapi/cli": patch
---

Add timeout to registry URL validation to prevent CLI hang when registry is unreachable.
35 changes: 30 additions & 5 deletions src/utils/generate/registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +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?:/;
Expand All @@ -8,12 +18,27 @@

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);

let response: Response;
try {
const response = await fetch(registryUrl as string);
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');
response = await fetch(registryUrl as string, {

Check warning on line 27 in src/utils/generate/registry.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ00uMbGo5CI2C-Y2rAn&open=AZ00uMbGo5CI2C-Y2rAn&pullRequest=2086
method: 'HEAD',
signal: controller.signal,
});
} 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}`);
}
} catch {
throw new Error(`Can't fetch registryURL: ${registryUrl}`);
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');
}
}
80 changes: 80 additions & 0 deletions test/unit/utils/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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 () => {
// 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 {
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('timed out');
} finally {
global.fetch = originalFetch;
}
});

it('should throw auth error for 401 without credentials', async () => {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case is empty (no assertions and no skip), so it currently provides no coverage and can be misleading. Either implement it (e.g., stub fetch to return a Response with status 401) or mark it as skipped/todo so it doesn’t look like coverage exists when it doesn’t.

Suggested change
it('should throw auth error for 401 without credentials', async () => {
it.skip('should throw auth error for 401 without credentials', async () => {

Copilot uses AI. Check for mistakes.
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;
}
});
});
Loading