Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as https from 'node:https';
import type { Notice, NoticeDataSource } from './types';
import { ToolkitError } from '../../toolkit/toolkit-error';
import { formatErrorMessage, humanHttpStatusError, humanNetworkError } from '../../util';
import { NetworkDetector } from '../../util/network-detector';
import type { IoHelper } from '../io/private';

/**
Expand Down Expand Up @@ -44,6 +45,12 @@ export class WebsiteNoticeDataSource implements NoticeDataSource {
}

async fetch(): Promise<Notice[]> {
// Check connectivity before attempting network request
const hasConnectivity = await NetworkDetector.hasConnectivity(this.agent);
if (!hasConnectivity) {
throw new ToolkitError('No internet connectivity detected');
}

// We are observing lots of timeouts when running in a massively parallel
// integration test environment, so wait for a longer timeout there.
//
Expand Down
58 changes: 58 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Agent } from 'https';
import { request } from 'https';

/**
* Detects internet connectivity by making a lightweight request to the notices endpoint
*/
export class NetworkDetector {
/**
* Check if internet connectivity is available
*/
public static async hasConnectivity(agent?: Agent): Promise<boolean> {
const now = Date.now();

// Return cached result if still valid
if (this.cachedResult !== undefined && now < this.cacheExpiry) {
return this.cachedResult;
}

try {
const connected = await this.ping(agent);
this.cachedResult = connected;
this.cacheExpiry = now + this.CACHE_DURATION_MS;
return connected;
} catch {
this.cachedResult = false;
this.cacheExpiry = now + this.CACHE_DURATION_MS;
return false;
}
}

private static readonly CACHE_DURATION_MS = 30_000; // 30 seconds
private static readonly TIMEOUT_MS = 500;

private static cachedResult: boolean | undefined;
private static cacheExpiry: number = 0;

private static ping(agent?: Agent): Promise<boolean> {
return new Promise((resolve) => {
const req = request({
hostname: 'cli.cdk.dev-tools.aws.dev',
path: '/notices.json',
method: 'HEAD',
agent,
timeout: this.TIMEOUT_MS,
}, (res) => {
resolve(res.statusCode !== undefined && res.statusCode < 500);
});

req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});

req.end();
});
}
}
19 changes: 19 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FilteredNotice, NoticesFilter } from '../../lib/api/notices/filter';
import type { BootstrappedEnvironment, Component, Notice } from '../../lib/api/notices/types';
import { WebsiteNoticeDataSource } from '../../lib/api/notices/web-data-source';
import { Settings } from '../../lib/api/settings';
import { NetworkDetector } from '../../lib/util/network-detector';
import { TestIoHost } from '../_helpers';

const BASIC_BOOTSTRAP_NOTICE = {
Expand Down Expand Up @@ -540,6 +541,24 @@ function parseTestComponent(x: string): Component {
describe(WebsiteNoticeDataSource, () => {
const dataSource = new WebsiteNoticeDataSource(ioHelper);

beforeEach(() => {
// Mock NetworkDetector to return true by default for existing tests
jest.spyOn(NetworkDetector, 'hasConnectivity').mockResolvedValue(true);
});

afterEach(() => {
jest.restoreAllMocks();
});

test('throws error when no connectivity detected', async () => {
const mockHasConnectivity = jest.spyOn(NetworkDetector, 'hasConnectivity').mockResolvedValue(false);

await expect(dataSource.fetch()).rejects.toThrow('No internet connectivity detected');
expect(mockHasConnectivity).toHaveBeenCalledWith(undefined);

mockHasConnectivity.mockRestore();
});

test('returns data when download succeeds', async () => {
const result = await mockCall(200, {
notices: [BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE],
Expand Down
104 changes: 104 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as https from 'https';
import { NetworkDetector } from '../../lib/util/network-detector';

// Mock the https module
jest.mock('https');
const mockHttps = https as jest.Mocked<typeof https>;

describe('NetworkDetector', () => {
let mockRequest: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
mockRequest = jest.fn();
mockHttps.request.mockImplementation(mockRequest);

// Clear static cache between tests
(NetworkDetector as any).cachedResult = undefined;
(NetworkDetector as any).cacheExpiry = 0;
});

test('returns true when server responds with success status', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_options, callback) => {
callback({ statusCode: 200 });
return mockReq;
});

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(true);
});

test('returns false when server responds with server error', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_options, callback) => {
callback({ statusCode: 500 });
return mockReq;
});

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false);
});

test('returns false on network error', async () => {
const mockReq = {
on: jest.fn((event, handler) => {
if (event === 'error') {
setTimeout(() => handler(new Error('Network error')), 0);
}
}),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockReturnValue(mockReq);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false);
});

test('returns false on timeout', async () => {
const mockReq = {
on: jest.fn((event, handler) => {
if (event === 'timeout') {
setTimeout(() => handler(), 0);
}
}),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockReturnValue(mockReq);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false);
});

test('caches result for subsequent calls', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_options, callback) => {
callback({ statusCode: 200 });
return mockReq;
});

await NetworkDetector.hasConnectivity();
await NetworkDetector.hasConnectivity();

expect(mockRequest).toHaveBeenCalledTimes(1);
});
});
9 changes: 8 additions & 1 deletion packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IncomingMessage } from 'http';
import type { Agent } from 'https';
import { request } from 'https';
import { parse, type UrlWithStringQuery } from 'url';
import { ToolkitError } from '@aws-cdk/toolkit-lib';
import { ToolkitError, NetworkDetector } from '@aws-cdk/toolkit-lib';
import { IoHelper } from '../../../api-private';
import type { IIoHost } from '../../io-host';
import type { TelemetrySchema } from '../schema';
Expand Down Expand Up @@ -94,6 +94,13 @@ export class EndpointTelemetrySink implements ITelemetrySink {
url: UrlWithStringQuery,
body: { events: TelemetrySchema[] },
): Promise<boolean> {
// Check connectivity before attempting network request
const hasConnectivity = await NetworkDetector.hasConnectivity(this.agent);
if (!hasConnectivity) {
await this.ioHelper.defaults.trace('No internet connectivity detected, skipping telemetry');
return false;
}

try {
const res = await doRequest(url, body, this.agent);

Expand Down
28 changes: 28 additions & 0 deletions packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,30 @@ import { createTestEvent } from './util';
import { IoHelper } from '../../../../lib/api-private';
import { CliIoHost } from '../../../../lib/cli/io-host';
import { EndpointTelemetrySink } from '../../../../lib/cli/telemetry/sink/endpoint-sink';
import { NetworkDetector } from '@aws-cdk/toolkit-lib/lib/util/network-detector';

// Mock the https module
jest.mock('https', () => ({
request: jest.fn(),
}));

// Mock NetworkDetector
jest.mock('@aws-cdk/toolkit-lib', () => ({
...jest.requireActual('@aws-cdk/toolkit-lib'),
NetworkDetector: {
hasConnectivity: jest.fn(),
},
}));

describe('EndpointTelemetrySink', () => {
let ioHost: CliIoHost;

beforeEach(() => {
jest.resetAllMocks();

// Mock NetworkDetector to return true by default for existing tests
(NetworkDetector.hasConnectivity as jest.Mock).mockResolvedValue(true);

ioHost = CliIoHost.instance();
});

Expand Down Expand Up @@ -312,4 +324,20 @@ describe('EndpointTelemetrySink', () => {
expect.stringContaining('Telemetry Error: POST example.com/telemetry:'),
);
});

test('skips request when no connectivity detected', async () => {
// GIVEN
(NetworkDetector.hasConnectivity as jest.Mock).mockResolvedValue(false);

const testEvent = createTestEvent('INVOKE', { foo: 'bar' });
const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost });

// WHEN
await client.emit(testEvent);
await client.flush();

// THEN
expect(NetworkDetector.hasConnectivity).toHaveBeenCalledWith(undefined);
expect(https.request).not.toHaveBeenCalled();
});
});
Loading