diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts index eb56decdc296..0d24a12b536c 100644 --- a/app/scripts/controllers/metametrics-controller.test.ts +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -26,6 +26,7 @@ import { OS, PLATFORM_CHROME, } from '../../../shared/constants/app'; +import Analytics from '../lib/segment/analytics'; import { createSegmentMock } from '../lib/segment'; import { METAMETRICS_ANONYMOUS_ID, @@ -1922,9 +1923,12 @@ describe('MetaMetricsController', function () { it('should remove event from store when callback is invoked', async function () { const segmentInstance = createSegmentMock(2); - const stubFn = (...args: unknown[]) => { - const cb = args[1] as () => void; - cb(); + const stubFn = ( + _message: Parameters[0], + callback?: Parameters[1], + ): Analytics => { + callback?.(); + return segmentInstance as Analytics; }; jest.spyOn(segmentInstance, 'track').mockImplementation(stubFn); jest.spyOn(segmentInstance, 'page').mockImplementation(stubFn); diff --git a/app/scripts/lib/segment/analytics.test.js b/app/scripts/lib/segment/analytics.test.js deleted file mode 100644 index 33844bb9f6a3..000000000000 --- a/app/scripts/lib/segment/analytics.test.js +++ /dev/null @@ -1,116 +0,0 @@ -import Analytics from './analytics'; - -const DUMMY_KEY = 'DUMMY_KEY'; -const DUMMY_MESSAGE = { - userId: 'userId', - idValue: 'idValue', - event: 'event', -}; -const FLUSH_INTERVAL = 10000; - -global.setImmediate = (arg) => { - arg(); -}; - -global.fetch = () => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({ success: true }), - }); - -describe('Analytics', function () { - let analytics; - - beforeEach(() => { - analytics = new Analytics(DUMMY_KEY); - }); - - describe('#flush', function () { - it('first message is immediately flushed', function () { - const mock = jest.fn(analytics.flush); - analytics.flush = mock; - analytics.track(DUMMY_MESSAGE); - expect(analytics.queue).toHaveLength(0); - expect(mock).toHaveBeenCalledTimes(1); - }); - - it('after first message it is called when queue size equals flushAt value', function () { - analytics = new Analytics(DUMMY_KEY, { flushAt: 3 }); - const mock = jest.fn(analytics.flush); - analytics.flush = mock; - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - expect(analytics.queue).toHaveLength(0); - expect(mock).toHaveBeenCalledTimes(2); - }); - - it('except for first message it is called until queue size is less than flushAt value', function () { - analytics = new Analytics(DUMMY_KEY, { flushAt: 3 }); - const mock = jest.fn(analytics.flush); - analytics.flush = mock; - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - expect(analytics.queue).toHaveLength(2); - expect(mock).toHaveBeenCalledTimes(1); - }); - - it('after first message it is called after flushInterval is elapsed', function () { - jest.useFakeTimers(); - analytics = new Analytics(DUMMY_KEY, { flushInterval: FLUSH_INTERVAL }); - const mock = jest.fn(analytics.flush); - analytics.flush = mock; - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - jest.advanceTimersByTime(FLUSH_INTERVAL); - expect(analytics.queue).toHaveLength(0); - expect(mock).toHaveBeenCalledTimes(2); - }); - - it('after first message it is not called until flushInterval is elapsed', function () { - jest.useFakeTimers(); - analytics = new Analytics(DUMMY_KEY, { flushInterval: FLUSH_INTERVAL }); - const mock = jest.fn(analytics.flush); - analytics.flush = mock; - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - jest.advanceTimersByTime(FLUSH_INTERVAL - 100); - expect(analytics.queue).toHaveLength(1); - expect(mock).toHaveBeenCalledTimes(1); - }); - - it('invokes callbacks', async function () { - const callback = jest.fn(); - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE, callback); - await analytics.flush(); - expect(callback).toHaveBeenCalledTimes(1); - }); - }); - - describe('#track', function () { - it('adds messages to ququq', function () { - analytics.track(DUMMY_MESSAGE); - analytics.track(DUMMY_MESSAGE); - expect(analytics.queue).toHaveLength(1); - }); - }); - - describe('#page', function () { - it('adds messages to ququq', function () { - analytics.page(DUMMY_MESSAGE); - analytics.page(DUMMY_MESSAGE); - expect(analytics.queue).toHaveLength(1); - }); - }); - - describe('#identify', function () { - it('adds messages to ququq', function () { - analytics.identify(DUMMY_MESSAGE); - analytics.identify(DUMMY_MESSAGE); - expect(analytics.queue).toHaveLength(1); - }); - }); -}); diff --git a/app/scripts/lib/segment/analytics.test.ts b/app/scripts/lib/segment/analytics.test.ts new file mode 100644 index 000000000000..7cc26757488f --- /dev/null +++ b/app/scripts/lib/segment/analytics.test.ts @@ -0,0 +1,328 @@ +import Analytics from './analytics'; + +const DUMMY_KEY = 'DUMMY_KEY'; +const DUMMY_MESSAGE = { + userId: 'userId', + idValue: 'idValue', + event: 'event', +}; +const FLUSH_INTERVAL = 10000; + +function defaultFetchSuccess(): Promise { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }), + } as Response); +} + +function installSyncSetImmediate(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).setImmediate = (arg: () => void) => { + arg(); + }; +} + +installSyncSetImmediate(); + +describe('Analytics', function () { + let analytics: Analytics; + let mockFetch: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn().mockImplementation(defaultFetchSuccess); + globalThis.fetch = mockFetch; + analytics = new Analytics(DUMMY_KEY); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + installSyncSetImmediate(); + }); + + describe('constructor', () => { + it('uses a minimum flushAt of 1 when 0 is passed', () => { + const instance = new Analytics(DUMMY_KEY, { flushAt: 0 }); + expect(instance.flushAt).toBe(1); + }); + + it('strips a trailing slash from the host option', () => { + const instance = new Analytics(DUMMY_KEY, { + host: 'https://custom.segment.example/', + }); + expect(instance.host).toBe('https://custom.segment.example'); + }); + }); + + describe('#enqueue', () => { + it('stringifies non-string anonymousId and userId for the batch payload', async () => { + jest + .spyOn(Analytics.prototype, '_validate') + .mockImplementation(() => undefined); + analytics = new Analytics(DUMMY_KEY); + const callback = jest.fn(); + analytics.track( + { + event: 'Test Event', + anonymousId: { a: 1 }, + userId: { id: 'x' }, + }, + callback, + ); + await analytics.flush(); + expect(callback).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + batch: [ + expect.objectContaining({ + anonymousId: JSON.stringify({ a: 1 }), + userId: JSON.stringify({ id: 'x' }), + }), + ], + }), + ); + }); + }); + + describe('#flush', function () { + it('first message is immediately flushed', function () { + const mock = jest.fn(analytics.flush.bind(analytics)); + analytics.flush = mock; + analytics.track(DUMMY_MESSAGE); + expect(analytics.queue).toHaveLength(0); + expect(mock).toHaveBeenCalledTimes(1); + }); + + it('after first message it is called when queue size equals flushAt value', function () { + analytics = new Analytics(DUMMY_KEY, { flushAt: 3 }); + const mock = jest.fn(analytics.flush.bind(analytics)); + analytics.flush = mock; + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + expect(analytics.queue).toHaveLength(0); + expect(mock).toHaveBeenCalledTimes(2); + }); + + it('except for first message it is called until queue size is less than flushAt value', function () { + analytics = new Analytics(DUMMY_KEY, { flushAt: 3 }); + const mock = jest.fn(analytics.flush.bind(analytics)); + analytics.flush = mock; + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + expect(analytics.queue).toHaveLength(2); + expect(mock).toHaveBeenCalledTimes(1); + }); + + it('after first message it is called after flushInterval is elapsed', function () { + jest.useFakeTimers(); + analytics = new Analytics(DUMMY_KEY, { flushInterval: FLUSH_INTERVAL }); + const mock = jest.fn(analytics.flush.bind(analytics)); + analytics.flush = mock; + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + jest.advanceTimersByTime(FLUSH_INTERVAL); + expect(analytics.queue).toHaveLength(0); + expect(mock).toHaveBeenCalledTimes(2); + }); + + it('after first message it is not called until flushInterval is elapsed', function () { + jest.useFakeTimers(); + analytics = new Analytics(DUMMY_KEY, { flushInterval: FLUSH_INTERVAL }); + const mock = jest.fn(analytics.flush.bind(analytics)); + analytics.flush = mock; + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + jest.advanceTimersByTime(FLUSH_INTERVAL - 100); + expect(analytics.queue).toHaveLength(1); + expect(mock).toHaveBeenCalledTimes(1); + }); + + it('invokes callbacks', async function () { + const callback = jest.fn(); + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE, callback); + await analytics.flush(); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('resolves when the queue is already empty', async () => { + await expect(analytics.flush()).resolves.toBeUndefined(); + }); + + it('clears a scheduled flush timer when flush runs', async () => { + jest.useFakeTimers(); + analytics = new Analytics(DUMMY_KEY, { + flushInterval: FLUSH_INTERVAL, + flushAt: 10, + }); + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + expect(analytics.timer).not.toBeNull(); + await analytics.flush(); + expect(analytics.timer).toBeNull(); + }); + + it('preserves timestamp and messageId when already set on the message', async () => { + const timestamp = new Date('2020-05-01T12:00:00.000Z'); + const callback = jest.fn(); + analytics.track( + { + event: 'Test Event', + userId: 'u', + timestamp, + messageId: 'preset-msg-id', + }, + callback, + ); + await analytics.flush(); + expect(callback).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + batch: [ + expect.objectContaining({ + timestamp, + messageId: 'preset-msg-id', + }), + ], + }), + ); + }); + + it('posts to the configured host and path', async () => { + analytics = new Analytics(DUMMY_KEY, { + host: 'https://segment.test', + }); + analytics.track(DUMMY_MESSAGE); + await analytics.flush(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://segment.test/v1/batch', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /u), + }), + }), + ); + }); + + it('invokes callback with an error when the response is not ok and not retryable', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + } as Response); + const callback = jest.fn(); + analytics.track(DUMMY_MESSAGE, callback); + await analytics.flush(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Bad Request' }), + expect.any(Object), + ); + }); + + it('retries after a 500 response then succeeds on the next request', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + } as Response); + + const callback = jest.fn(); + analytics.track(DUMMY_MESSAGE, callback); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith(undefined, expect.any(Object)); + }); + + it('retries after a 429 response', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + } as Response); + + analytics.track(DUMMY_MESSAGE); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('retries when fetch rejects with a retryable network error', async () => { + const err = Object.assign(new Error('reset'), { code: 'ECONNRESET' }); + mockFetch.mockRejectedValueOnce(err).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + } as Response); + + analytics.track(DUMMY_MESSAGE); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('invokes callback with error when fetch rejects and the error is not retryable', async () => { + mockFetch.mockRejectedValue( + Object.assign(new Error('aborted'), { code: 'ECONNABORTED' }), + ); + const callback = jest.fn(); + analytics.track(DUMMY_MESSAGE, callback); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ message: 'aborted' }), + expect.any(Object), + ); + }); + }); + + describe('max queue size', () => { + it('flushes when estimated queue JSON size reaches the limit', async () => { + analytics = new Analytics(DUMMY_KEY, { flushAt: 100_000 }); + const pad = 'z'.repeat(24_000); + const payload = { ...DUMMY_MESSAGE, event: 'Large payload', pad }; + analytics.track({ ...DUMMY_MESSAGE, event: 'bootstrap' }); + for (let i = 0; i < 25; i++) { + analytics.track(payload); + } + await analytics.flush(); + expect(analytics.queue).toHaveLength(0); + }); + }); + + describe('#track', function () { + it('adds messages to queue', function () { + analytics.track(DUMMY_MESSAGE); + analytics.track(DUMMY_MESSAGE); + expect(analytics.queue).toHaveLength(1); + }); + }); + + describe('#page', function () { + it('adds messages to queue', function () { + analytics.page(DUMMY_MESSAGE); + analytics.page(DUMMY_MESSAGE); + expect(analytics.queue).toHaveLength(1); + }); + }); + + describe('#identify', function () { + it('adds messages to queue', function () { + analytics.identify(DUMMY_MESSAGE); + analytics.identify(DUMMY_MESSAGE); + expect(analytics.queue).toHaveLength(1); + }); + }); +}); diff --git a/app/scripts/lib/segment/analytics.js b/app/scripts/lib/segment/analytics.ts similarity index 57% rename from app/scripts/lib/segment/analytics.js rename to app/scripts/lib/segment/analytics.ts index 8966c8eebf7c..e2f6bf7435b0 100644 --- a/app/scripts/lib/segment/analytics.js +++ b/app/scripts/lib/segment/analytics.ts @@ -6,39 +6,108 @@ import { generateRandomId } from '../util'; const noop = () => ({}); +type AnalyticsCallback = (err?: Error, data?: FlushData) => void; + +type QueueItem = { + message: AnalyticsMessage; + callback: AnalyticsCallback; +}; + +type FlushData = { + batch: AnalyticsMessage[]; + timestamp: Date; + sentAt: Date; +}; + +type AnalyticsMessage = { + type?: string; + context?: { + library?: { + name: string; + }; + [key: string]: unknown; + }; + timestamp?: Date; + messageId?: string; + anonymousId?: unknown; + userId?: unknown; + [key: string]: unknown; +}; + +type AnalyticsOptions = { + flushAt?: number; + flushInterval?: number; + host?: string; +}; + +type RequestBody = { + method: string; + body: string; + headers: Record; +}; + +type RetryableError = { + response?: Response; + code?: string; +}; + // Method below is inspired from axios-retry https://github.com/softonic/axios-retry -function isNetworkError(error) { +function isNetworkError(error: RetryableError): boolean { return ( !error.response && Boolean(error.code) && // Prevents retrying cancelled requests error.code !== 'ECONNABORTED' && // Prevents retrying timed out requests - isRetryAllowed(error) + isRetryAllowed(error as Error) ); // Prevents retrying unsafe errors } export default class Analytics { + writeKey: string; + + host: string; + + flushInterval: number; + + flushAt: number; + + queue: QueueItem[]; + + path: string; + + maxQueueSize: number; + + flushed: boolean; + + retryCount: number; + + enable!: boolean; + + timer: ReturnType | null; + /** * Initialize a new `Analytics` with Segment project's `writeKey` and an * optional dictionary of `options`. * - * @param {string} writeKey - * @param {object} [options] - (optional) - * @property {number} [flushAt] (default: 20) - * @property {number} [flushInterval] (default: 10000) - * @property {string} [host] (default: 'https://api.segment.io') + * @param writeKey - The Segment project write key. + * @param options - Optional configuration options. + * @param options.flushAt - Number of events to queue before flushing (default: 20). + * @param options.flushInterval - Interval in ms between flushes (default: 10000). + * @param options.host - The Segment API host (default: 'https://api.segment.io'). */ - constructor(writeKey, options = {}) { + constructor(writeKey: string, options: AnalyticsOptions = {}) { this.writeKey = writeKey; this.host = removeSlash(options.host || 'https://api.segment.io'); this.flushInterval = options.flushInterval || 10000; - this.flushAt = options.flushAt || Math.max(options.flushAt, 1) || 20; + this.flushAt = + options.flushAt === undefined ? 20 : Math.max(options.flushAt, 1); this.queue = []; this.path = '/v1/batch'; this.maxQueueSize = 1024 * 450; this.flushed = false; this.retryCount = 3; + this.timer = null; Object.defineProperty(this, 'enable', { configurable: false, @@ -48,11 +117,15 @@ export default class Analytics { }); } - _validate(message, type) { - looselyValidate(message, type); + _validate(message: AnalyticsMessage, type: string): void { + looselyValidate(message as Record, type); } - _message(type, message, callback) { + _message( + type: string, + message: AnalyticsMessage, + callback?: AnalyticsCallback, + ): this { this._validate(message, type); this.enqueue(type, message, callback); return this; @@ -61,33 +134,33 @@ export default class Analytics { /** * Send an identify `message`. * - * @param {object} message - * @param {Function} [callback] - (optional) - * @returns {Analytics} + * @param message - The identify message payload. + * @param callback - Optional callback invoked after the event is flushed. + * @returns The Analytics instance. */ - identify(message, callback) { + identify(message: AnalyticsMessage, callback?: AnalyticsCallback): this { return this._message('identify', message, callback); } /** * Send a track `message`. * - * @param {object} message - * @param {Function} [callback] - (optional) - * @returns {Analytics} + * @param message - The track message payload. + * @param callback - Optional callback invoked after the event is flushed. + * @returns The Analytics instance. */ - track(message, callback) { + track(message: AnalyticsMessage, callback?: AnalyticsCallback): this { return this._message('track', message, callback); } /** * Send a page `message`. * - * @param {object} message - * @param {Function} [callback] - (optional) - * @returns {Analytics} + * @param message - The page message payload. + * @param callback - Optional callback invoked after the event is flushed. + * @returns The Analytics instance. */ - page(message, callback) { + page(message: AnalyticsMessage, callback?: AnalyticsCallback): this { return this._message('page', message, callback); } @@ -95,17 +168,21 @@ export default class Analytics { * Add a `message` of type `type` to the queue and * check whether it should be flushed. * - * @param {string} type - * @param {object} msg - * @param {Function} [callback] - (optional) + * @param type - The type of the message (e.g. 'track', 'identify', 'page'). + * @param msg - The message payload. + * @param callback - Optional callback invoked after the event is flushed. */ - enqueue(type, msg, callback = noop) { + enqueue( + type: string, + msg: AnalyticsMessage, + callback: AnalyticsCallback = noop, + ): void { if (!this.enable) { setImmediate(callback); return; } - const message = { ...msg, type }; + const message: AnalyticsMessage = { ...msg, type }; // Specifying library here helps segment to understand structure of request. // Currently segment seems to support these source libraries only. @@ -154,9 +231,10 @@ export default class Analytics { /** * Flush the current queue * - * @param {Function} [callback] - (optional) + * @param callback - Optional callback invoked after the queue is flushed. + * @returns A promise that resolves when the flush is complete. */ - flush(callback = noop) { + flush(callback: AnalyticsCallback = noop): Promise | undefined { if (!this.enable) { setImmediate(callback); return Promise.resolve(); @@ -176,20 +254,20 @@ export default class Analytics { const callbacks = items.map((item) => item.callback); const messages = items.map((item) => item.message); - const data = { + const data: FlushData = { batch: messages, timestamp: new Date(), sentAt: new Date(), }; - const done = (err) => { + const done = (err?: Error) => { setImmediate(() => { callbacks.forEach((fn) => fn(err, data)); callback(err, data); }); }; - const headers = { + const headers: Record = { Authorization: `Basic ${Buffer.from(this.writeKey, 'utf8').toString( 'base64', )}`, @@ -207,14 +285,24 @@ export default class Analytics { ); } - _retryRequest(url, body, done, retryNo) { + _retryRequest( + url: string, + body: RequestBody, + done: (err?: Error) => void, + retryNo: number, + ): void { const delay = Math.pow(2, retryNo) * 100; setTimeout(() => { this._sendRequest(url, body, done, retryNo + 1); }, delay); } - async _sendRequest(url, body, done, retryNo) { + async _sendRequest( + url: string, + body: RequestBody, + done: (err?: Error) => void, + retryNo: number, + ): Promise { return fetch(url, body) .then(async (response) => { if (response.ok) { @@ -229,8 +317,11 @@ export default class Analytics { done(error); } }) - .catch((error) => { - if (this._isErrorRetryable(error) && retryNo <= this.retryCount) { + .catch((error: Error) => { + if ( + this._isErrorRetryable(error as RetryableError) && + retryNo <= this.retryCount + ) { this._retryRequest(url, body, done, retryNo); } else { done(error); @@ -238,7 +329,7 @@ export default class Analytics { }); } - _isErrorRetryable(error) { + _isErrorRetryable(error: RetryableError): boolean { // Retry Network Errors. if (isNetworkError(error)) { return true; diff --git a/development/ts-migration-dashboard/files-to-convert.json b/development/ts-migration-dashboard/files-to-convert.json index a08bd5051a2a..a779680b9a59 100644 --- a/development/ts-migration-dashboard/files-to-convert.json +++ b/development/ts-migration-dashboard/files-to-convert.json @@ -54,8 +54,6 @@ "app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.test.js", "app/scripts/lib/rpc-method-middleware/handlers/watch-asset.test.js", "app/scripts/lib/rpc-method-middleware/index.js", - "app/scripts/lib/segment/analytics.js", - "app/scripts/lib/segment/analytics.test.js", "app/scripts/lib/segment/index.js", "app/scripts/lib/setup-initial-state-hooks.js", "app/scripts/lib/setupSentry.js", diff --git a/types/segment.d.ts b/types/segment.d.ts new file mode 100644 index 000000000000..91d51a245f51 --- /dev/null +++ b/types/segment.d.ts @@ -0,0 +1,36 @@ +declare module '@segment/loosely-validate-event' { + /** + * Loosely validates a Segment event message. + * + * @param message - The event message to validate. + * @param type - The type of the event (e.g. 'track', 'identify', 'page'). + * @throws {Error} If the message is invalid. + */ + function looselyValidate( + message: Record, + type: string, + ): void; + export = looselyValidate; +} + +declare module 'remove-trailing-slash' { + /** + * Removes a trailing slash from a URL string. + * + * @param url - The URL string to process. + * @returns The URL string without a trailing slash. + */ + function removeTrailingSlash(url: string): string; + export = removeTrailingSlash; +} + +declare module 'is-retry-allowed' { + /** + * Determines if a request should be retried based on the error. + * + * @param error - The error to evaluate. + * @returns `true` if the request should be retried, `false` otherwise. + */ + function isRetryAllowed(error: Error): boolean; + export = isRetryAllowed; +}