Skip to content
5 changes: 4 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@ export class Notices {
* @throws on failure to refresh the data source
*/
public async refresh(options: NoticesRefreshOptions = {}) {
const innerDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.ioHelper, this.httpOptions);
const innerDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.ioHelper, {
...this.httpOptions,
skipNetworkCache: options.force,
});
const dataSource = new CachedDataSource(this.ioHelper, CACHE_FILE_PATH, innerDataSource, options.force ?? false);
const notices = await dataSource.fetch();
this.data = new Set(notices);
Expand Down
16 changes: 16 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts
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 All @@ -20,6 +21,7 @@ export class WebsiteNoticeDataSourceProps {
* @default - Official CDK notices
*/
readonly url?: string | URL;

/**
* The agent responsible for making the network requests.
*
Expand All @@ -28,6 +30,14 @@ export class WebsiteNoticeDataSourceProps {
* @default - Uses the shared global node agent
*/
readonly agent?: https.Agent;

/**
* Whether or not we want to skip the check for if we have already determined we are in
* a network-less environment. Forces WebsiteNoticeDataSource to make a network call.
*
* @default false
*/
readonly skipNetworkCache?: boolean;
}

export class WebsiteNoticeDataSource implements NoticeDataSource {
Expand All @@ -44,6 +54,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');
Copy link
Contributor

Choose a reason for hiding this comment

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

Is throwing the right thing here? Is that error caught elsewhere? Asking because Notices should just silently fail. A comment might help.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is the right thing to do here. we are throwing errors in web-data-source on failures and expecting to swallow them elsewhere (which we do)

Copy link
Contributor

Choose a reason for hiding this comment

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

[non-blocking] Since this pattern will be very common (get the result, check whether it's true and throw an error if not), we could also have a method that takes a callback and does this for you:

return NetworkDetector.ifConnected(async() => {...});

}

// We are observing lots of timeouts when running in a massively parallel
// integration test environment, so wait for a longer timeout there.
//
Expand Down
3 changes: 3 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ export * from './api/cloud-assembly';
export * from './api/io';
export * from './api/tags';
export * from './api/plugin';

// Utilities
export { NetworkDetector } from './util/network-detector';
89 changes: 89 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,89 @@
import * as https from 'node:https';
import * as path from 'path';
import * as fs from 'fs-extra';
import { cdkCacheDir } from './';

interface CachedConnectivity {
expiration: number;
hasConnectivity: boolean;
}

const TIME_TO_LIVE_SUCCESS = 60 * 60 * 1000; // 1 hour
const CACHE_FILE_PATH = path.join(cdkCacheDir(), 'connection.json');

/**
* 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?: https.Agent): Promise<boolean> {
const cachedData = await this.load();
const expiration = cachedData.expiration ?? 0;

if (Date.now() > expiration) {
try {
const connected = await this.ping(agent);
const updatedData = {
expiration: Date.now() + TIME_TO_LIVE_SUCCESS,
hasConnectivity: connected,
};
await this.save(updatedData);
return connected;
} catch {
return false;
}
} else {
return cachedData.hasConnectivity;
}
}

private static readonly TIMEOUT_MS = 500;

private static async load(): Promise<CachedConnectivity> {
const defaultValue = {
expiration: 0,
hasConnectivity: false,
};

try {
return fs.existsSync(CACHE_FILE_PATH)
? await fs.readJSON(CACHE_FILE_PATH) as CachedConnectivity
: defaultValue;
} catch {
return defaultValue;
}
}

private static async save(cached: CachedConnectivity): Promise<void> {
try {
await fs.ensureFile(CACHE_FILE_PATH);
await fs.writeJSON(CACHE_FILE_PATH, cached);
} catch {
// Silently ignore cache save errors
}
}

private static ping(agent?: https.Agent): Promise<boolean> {
return new Promise((resolve) => {
const req = https.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
169 changes: 169 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,169 @@
import * as https from 'node:https';
import * as fs from 'fs-extra';
import { NetworkDetector } from '../../lib/util/network-detector';

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

// Mock fs-extra
jest.mock('fs-extra');
const mockFs = fs as jest.Mocked<typeof fs>;

// Mock cdkCacheDir
jest.mock('../../lib/util', () => ({
cdkCacheDir: jest.fn(() => '/mock/cache/dir'),
}));

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

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

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

mockFs.existsSync.mockReturnValue(false);
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(true); // Should return true for successful HTTP response
});

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

mockFs.existsSync.mockReturnValue(false);
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false); // Should return false for server error status codes
});

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);
mockFs.existsSync.mockReturnValue(false);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false); // Should return false when network request fails
});

test('returns cached result from disk when not expired', async () => {
const cachedData = {
expiration: Date.now() + 30000, // 30 seconds in future
hasConnectivity: true,
};

mockFs.existsSync.mockReturnValue(true);
(mockFs.readJSON as jest.Mock).mockResolvedValue(cachedData);

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should return cached connectivity result
expect(mockRequest).not.toHaveBeenCalled(); // Should not make network request when cache is valid
});

test('performs ping when disk cache is expired', async () => {
const expiredData = {
expiration: Date.now() - 1000, // 1 second ago
hasConnectivity: true,
};

const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

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

mockFs.existsSync.mockReturnValue(true);
(mockFs.readJSON as jest.Mock).mockResolvedValue(expiredData);
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should return fresh connectivity result
expect(mockRequest).toHaveBeenCalledTimes(1); // Should make network request when cache is expired
});

test('handles cache save errors gracefully', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

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

mockFs.existsSync.mockReturnValue(false);
(mockFs.ensureFile as jest.Mock).mockRejectedValue(new Error('Disk full'));

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should still return connectivity result despite cache save failure
});

test('handles cache load errors gracefully', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

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

mockFs.existsSync.mockReturnValue(true);
(mockFs.readJSON as jest.Mock).mockRejectedValue(new Error('Read failed'));
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should still return connectivity result despite cache load failure
});
});
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 @@ -89,6 +89,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
Loading
Loading