diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts index 6dcf4cc72..8519442d9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts @@ -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'; /** @@ -44,6 +45,12 @@ export class WebsiteNoticeDataSource implements NoticeDataSource { } async fetch(): Promise { + // 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. // diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts b/packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts new file mode 100644 index 000000000..9bb1eb9b2 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts @@ -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 { + 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 { + 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(); + }); + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts index e79c15e42..cbdcc1a36 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts @@ -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 = { @@ -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], diff --git a/packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts b/packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts new file mode 100644 index 000000000..0fe16cc24 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts @@ -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; + +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); + }); +}); diff --git a/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts b/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts index 67fd718fa..8ef6a1e5f 100644 --- a/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts +++ b/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts @@ -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'; @@ -94,6 +94,13 @@ export class EndpointTelemetrySink implements ITelemetrySink { url: UrlWithStringQuery, body: { events: TelemetrySchema[] }, ): Promise { + // 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); diff --git a/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts index db7d8e182..44263127e 100644 --- a/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts @@ -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(); }); @@ -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(); + }); });