diff --git a/README.md b/README.md index fd47b6c..5b6e533 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ A string defining the Rollbar API endpoint to upload the sourcemaps to. It can b Set to true to encode the filename. NextJS will reference the encode the URL when referencing the minified script which must match exactly with the minified file URL uploaded to Rollbar. +### `maxRetries: string` **(default: `0`)** + +The maximum number of times to retry if uploading fails. Retries are implemented with exponential backoff to avoid inundating a potentially overloaded server. + ## Webpack Sourcemap Configuration The [`output.devtool`](https://webpack.js.org/configuration/devtool/) field in webpack configuration controls how sourcemaps are generated. diff --git a/src/RollbarSourceMapPlugin.js b/src/RollbarSourceMapPlugin.js index 33efaf8..9f9fd0d 100644 --- a/src/RollbarSourceMapPlugin.js +++ b/src/RollbarSourceMapPlugin.js @@ -7,6 +7,10 @@ import VError from 'verror'; import { handleError, validateOptions } from './helpers'; import { PLUGIN_NAME, ROLLBAR_ENDPOINT } from './constants'; +function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + class RollbarSourceMapPlugin { constructor({ accessToken, @@ -16,7 +20,8 @@ class RollbarSourceMapPlugin { silent = false, ignoreErrors = false, rollbarEndpoint = ROLLBAR_ENDPOINT, - encodeFilename = false + encodeFilename = false, + maxRetries = 0 }) { this.accessToken = accessToken; this.version = version; @@ -26,6 +31,7 @@ class RollbarSourceMapPlugin { this.ignoreErrors = ignoreErrors; this.rollbarEndpoint = rollbarEndpoint; this.encodeFilename = encodeFilename; + this.maxRetries = maxRetries; } async afterEmit(compilation) { @@ -164,9 +170,37 @@ class RollbarSourceMapPlugin { process.stdout.write('\n'); } return Promise.all( - assets.map(asset => this.uploadSourceMap(compilation, asset)) + assets.map(asset => this.uploadSourceMapWithRetry(compilation, asset)) ); } + + async uploadSourceMapWithRetry(compilation, asset, depth = 0) { + try { + if (!this.silent) { + // eslint-disable-next-line no-console + console.info(`Uploading ${asset.sourceMap} to Rollbar`); + } + return await this.uploadSourceMap(compilation, asset); + } catch (error) { + if (depth >= this.maxRetries) { + throw error; + } + // Delay with exponential backoff + const delay = 2 ** depth * 10; + if (!this.silent) { + // eslint-disable-next-line no-console + console.info( + `Uploading ${asset.sourceMap} to Rollbar failed with error: ${ + error.message || error + }` + ); + // eslint-disable-next-line no-console + console.info(`Retrying in ${delay}ms...`); + } + await wait(delay); + return this.uploadSourceMapWithRetry(compilation, asset, depth + 1); + } + } } module.exports = RollbarSourceMapPlugin; diff --git a/test/RollbarSourceMapPlugin.test.js b/test/RollbarSourceMapPlugin.test.js index 288619a..ea890b2 100644 --- a/test/RollbarSourceMapPlugin.test.js +++ b/test/RollbarSourceMapPlugin.test.js @@ -551,4 +551,82 @@ describe('RollbarSourceMapPlugin', () => { ); }); }); + + describe('uploadSourceMapWithRetry', () => { + let compilation; + let chunk; + let info; + const err = new Error('502 - Bad Gateway'); + let uploadSourceMapSpy; + + beforeEach(() => { + info = jest.spyOn(console, 'info').mockImplementation(); + compilation = {}; + + chunk = { + sourceFile: 'vendor.5190.js', + sourceMap: 'vendor.5190.js.map' + }; + }); + + it('calls uploadSourceMap once if no errors are thrown', async () => { + uploadSourceMapSpy = jest + .spyOn(plugin, 'uploadSourceMap') + .mockImplementation(); + + await plugin.uploadSourceMapWithRetry(compilation, chunk); + expect(uploadSourceMapSpy).toHaveBeenCalledTimes(1); + expect(uploadSourceMapSpy).toHaveBeenCalledWith(compilation, chunk); + expect(info).toHaveBeenCalledWith( + 'Uploading vendor.5190.js.map to Rollbar' + ); + }); + + it('throws when uploadSourceMap fails, if maxRetries is not set', async () => { + uploadSourceMapSpy = jest + .spyOn(plugin, 'uploadSourceMap') + .mockImplementationOnce(() => Promise.reject(err)); + + await expect( + plugin.uploadSourceMapWithRetry(compilation, chunk) + ).rejects.toThrow(err); + expect(uploadSourceMapSpy).toHaveBeenCalledWith(compilation, chunk); + }); + + it('retries when uploadSourceMap fails', async () => { + plugin.maxRetries = 1; + uploadSourceMapSpy = jest + .spyOn(plugin, 'uploadSourceMap') + .mockImplementation(() => Promise.reject(err)); + + await expect( + plugin.uploadSourceMapWithRetry(compilation, chunk) + ).rejects.toThrow(err); + expect(uploadSourceMapSpy).toHaveBeenCalledTimes(2); + expect(uploadSourceMapSpy).toHaveBeenNthCalledWith(2, compilation, chunk); + expect(info).toHaveBeenCalledWith( + 'Uploading vendor.5190.js.map to Rollbar failed with error: 502 - Bad Gateway' + ); + }); + + it('succeeds with exponential backoff when uploadSourceMap succeeds on a subsequent try', async () => { + plugin.maxRetries = 5; + uploadSourceMapSpy = jest + .spyOn(plugin, 'uploadSourceMap') + .mockImplementationOnce(() => Promise.reject(err)) + .mockImplementationOnce(() => Promise.reject(err)) + .mockImplementationOnce(() => Promise.reject(err)) + .mockImplementationOnce(() => Promise.reject(err)) + .mockImplementationOnce(() => Promise.reject(err)) + .mockImplementationOnce(() => undefined); + + await plugin.uploadSourceMapWithRetry(compilation, chunk); + expect(uploadSourceMapSpy).toHaveBeenCalledTimes(6); + expect(info).toHaveBeenCalledWith('Retrying in 10ms...'); + expect(info).toHaveBeenCalledWith('Retrying in 20ms...'); + expect(info).toHaveBeenCalledWith('Retrying in 40ms...'); + expect(info).toHaveBeenCalledWith('Retrying in 80ms...'); + expect(info).toHaveBeenCalledWith('Retrying in 160ms...'); + }); + }); });