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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TokenManager } from './TokenManager';
import { PubNubFunctionClient } from './PubnubFunctionClient';
import { StorageKeys } from '../../StorageKeys';
import { CachedTopics, PubNubProviderOptions } from './types';
import { withNetworkErrorHandling } from './transformNetworkError';

/**
* Conversion factor: seconds per minute.
Expand Down Expand Up @@ -337,7 +338,9 @@ export class PubNubPollingProvider implements NotificationsProvider {
*/
private async fetchMessagesForTopic(topic: Topic): Promise<void> {
const end = (await this.storage.getItem<string>(this.storageKeys.getLastSync(topic.id))) || '0';
const response = await this.pubnub.fetchMessages({ channels: [topic.id], count: 100, end });
const response = await withNetworkErrorHandling(() =>
this.pubnub.fetchMessages({ channels: [topic.id], count: 100, end })
);
const messages = response.channels[topic.id];

if (messages) {
Expand Down Expand Up @@ -500,7 +503,9 @@ export class PubNubPollingProvider implements NotificationsProvider {
* @returns Promise that resolves to an array of available topics
*/
async refreshChannels(): Promise<Topic[]> {
const { data: channels } = await this.pubnub.objects.getAllChannelMetadata({ include: { customFields: true } });
const { data: channels } = await withNetworkErrorHandling(() =>
this.pubnub.objects.getAllChannelMetadata({ include: { customFields: true } })
);
const topics = channelsToTopics(channels, this.pubnub, this.logger);

this.storage.setItem(this.storageKeys.getTopics(), { lastFetch: Date.now(), topics });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TokenManager } from './TokenManager';
import { PubNubFunctionClient } from './PubnubFunctionClient';
import { StorageKeys } from '../../StorageKeys';
import { CachedTopics, ChannelsControlMessage, ChannelsControlMessagePUT, PubNubProviderOptions } from './types';
import { withNetworkErrorHandling } from './transformNetworkError';

const CHANNELS_CONTROL_CHANNEL = 'control.topics';

Expand Down Expand Up @@ -355,7 +356,9 @@ export class PubNubProvider implements NotificationsProvider {
for (const topic of this.topics)
if (topic.isSubscribed || subscribedTopics.includes(topic.id)) {
const end = (await this.storage.getItem<string>(this.storageKeys.getLastSync(topic.id))) || '0';
const response = await this.pubnub.fetchMessages({ channels: [topic.id], count: 100, end });
const response = await withNetworkErrorHandling(() =>
this.pubnub.fetchMessages({ channels: [topic.id], count: 100, end })
);
const messages = response.channels[topic.id];

if (messages)
Expand Down Expand Up @@ -520,7 +523,9 @@ export class PubNubProvider implements NotificationsProvider {
* @returns Promise that resolves to an array of available topics
*/
async refreshChannels(): Promise<Topic[]> {
const { data: channels } = await this.pubnub.objects.getAllChannelMetadata({ include: { customFields: true } });
const { data: channels } = await withNetworkErrorHandling(() =>
this.pubnub.objects.getAllChannelMetadata({ include: { customFields: true } })
);
const topics = channelsToTopics(channels, this.pubnub, this.logger);

this.storage.setItem(this.storageKeys.getTopics(), { lastFetch: Date.now(), topics });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Utility functions for handling PubNub network errors.
* Converts network-related PubNubError exceptions to TypeError instances
* that isNetworkError() recognizes, preventing Sentry from capturing transient network errors.
*/

/**
* Network-related PubNub error categories that should be converted to network errors.
*/
const NETWORK_ERROR_CATEGORIES = new Set(['PNTimeoutCategory', 'PNNetworkIssuesCategory', 'PNNetworkDownCategory']);

/**
* Checks if an error is a network-related PubNub error.
* Only checks the status.category field, not errorData.
*
* @param error - The error to check
* @returns True if the error is a network-related PubNub error
*/
export const isPubNubNetworkError = (error: unknown): boolean => {
// Check if error has a status property with category
if (error && typeof error === 'object' && 'status' in error) {
const status = error.status;
if (status && typeof status === 'object' && 'category' in status && typeof status.category === 'string') {
return NETWORK_ERROR_CATEGORIES.has(status.category);
}
}

return false;
};

/**
* Wraps an async PubNub operation and transforms network-related errors.
* Network-related PubNubErrors are converted to TypeError with message "Failed to fetch"
* so that isNetworkError() recognizes them and Sentry doesn't capture them.
*
* @param operation - The async operation to wrap
* @returns Promise that resolves to the operation result, or throws a transformed error
* @throws TypeError if the operation throws a network-related PubNubError
* @throws The original error if it's not a network-related PubNubError
*/
export const withNetworkErrorHandling = async <T>(operation: () => Promise<T>): Promise<T> => {
try {
return await operation();
} catch (error) {
if (isPubNubNetworkError(error)) {
// Convert to TypeError with message that isNetworkError() recognizes
throw new TypeError('Failed to fetch');
}
// Re-throw non-network errors as-is
throw error;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* eslint-disable no-magic-numbers */
import { isPubNubNetworkError, withNetworkErrorHandling } from '../../../src/providers/PubNub/transformNetworkError';

/**
* Creates a mock PubNubError with the given category.
*/
const createMockPubNubError = (category: string): unknown => ({
name: 'PubNubError',
message: 'REST API request processing error, check status for details',
status: {
category,
operation: 'PNFetchMessagesOperation',
statusCode: 0
}
});

describe('transformNetworkError', () => {
describe('isPubNubNetworkError', () => {
it('should return true for PNTimeoutCategory', () => {
const error = createMockPubNubError('PNTimeoutCategory');
expect(isPubNubNetworkError(error)).toBe(true);
});

it('should return true for PNNetworkIssuesCategory', () => {
const error = createMockPubNubError('PNNetworkIssuesCategory');
expect(isPubNubNetworkError(error)).toBe(true);
});

it('should return true for PNNetworkDownCategory', () => {
const error = createMockPubNubError('PNNetworkDownCategory');
expect(isPubNubNetworkError(error)).toBe(true);
});

it('should return false for non-network PubNub error categories', () => {
const error = createMockPubNubError('PNBadRequestCategory');
expect(isPubNubNetworkError(error)).toBe(false);
});

it('should return false for error without status', () => {
const error = { name: 'PubNubError', message: 'Some error' };
expect(isPubNubNetworkError(error)).toBe(false);
});

it('should return false for error without category', () => {
const error = { name: 'PubNubError', status: { operation: 'PNFetchMessagesOperation' } };
expect(isPubNubNetworkError(error)).toBe(false);
});

it('should return false for non-PubNub errors', () => {
const error = new Error('Some error');
expect(isPubNubNetworkError(error)).toBe(false);
});

it('should return false for null', () => {
// eslint-disable-next-line unicorn/no-null
expect(isPubNubNetworkError(null)).toBe(false);
});

it('should return false for undefined', () => {
// eslint-disable-next-line unicorn/no-useless-undefined
expect(isPubNubNetworkError(undefined)).toBe(false);
});
});

describe('withNetworkErrorHandling', () => {
it('should return result for successful operation', async () => {
const result = { data: 'test' };
const operation = jest.fn().mockResolvedValue(result);

const output = await withNetworkErrorHandling(operation);

expect(output).toBe(result);
expect(operation).toHaveBeenCalledTimes(1);
});

it('should convert PNTimeoutCategory error to TypeError', async () => {
const pubnubError = createMockPubNubError('PNTimeoutCategory');
const operation = jest.fn().mockRejectedValue(pubnubError);

await expect(withNetworkErrorHandling(operation)).rejects.toThrow(TypeError);
await expect(withNetworkErrorHandling(operation)).rejects.toThrow('Failed to fetch');
});

it('should convert PNNetworkIssuesCategory error to TypeError', async () => {
const pubnubError = createMockPubNubError('PNNetworkIssuesCategory');
const operation = jest.fn().mockRejectedValue(pubnubError);

await expect(withNetworkErrorHandling(operation)).rejects.toThrow(TypeError);
await expect(withNetworkErrorHandling(operation)).rejects.toThrow('Failed to fetch');
});

it('should convert PNNetworkDownCategory error to TypeError', async () => {
const pubnubError = createMockPubNubError('PNNetworkDownCategory');
const operation = jest.fn().mockRejectedValue(pubnubError);

await expect(withNetworkErrorHandling(operation)).rejects.toThrow(TypeError);
await expect(withNetworkErrorHandling(operation)).rejects.toThrow('Failed to fetch');
});

it('should re-throw non-network PubNubError as-is', async () => {
const pubnubError = createMockPubNubError('PNBadRequestCategory');
const operation = jest.fn().mockRejectedValue(pubnubError);

await expect(withNetworkErrorHandling(operation)).rejects.toBe(pubnubError);
});

it('should re-throw non-PubNubError as-is', async () => {
const error = new Error('Some other error');
const operation = jest.fn().mockRejectedValue(error);

await expect(withNetworkErrorHandling(operation)).rejects.toBe(error);
});

it('should re-throw TypeError as-is', async () => {
const error = new TypeError('Some type error');
const operation = jest.fn().mockRejectedValue(error);

await expect(withNetworkErrorHandling(operation)).rejects.toBe(error);
});

it('should preserve error stack for non-network errors', async () => {
const error = new Error('Some error');
error.stack = 'Error: Some error\n at test.js:1:1';
const operation = jest.fn().mockRejectedValue(error);

try {
await withNetworkErrorHandling(operation);
fail('Should have thrown');
} catch (thrownError) {
expect(thrownError).toBe(error);
expect((thrownError as Error).stack).toBe(error.stack);
}
});

it('should work with async operations that return promises', async () => {
const result = Promise.resolve({ data: 'async result' });
const operation = jest.fn().mockReturnValue(result);

const output = await withNetworkErrorHandling(operation);

expect(output).toEqual({ data: 'async result' });
});
});
});
Loading