From fc70b99eec210083782a38d8f11b3f97e86ef6b0 Mon Sep 17 00:00:00 2001 From: gololdf1sh Date: Mon, 18 May 2026 20:37:34 +0300 Subject: [PATCH] Fix recorder: stop mutating shared defaultRetryOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.assign(defaultRetryOptions, retryOpts)` returned and used the shared module-level `defaultRetryOptions` object as the merge target, so any custom retry option (e.g. `minTimeout`, `factor`) persisted into the defaults for every later `recorder.retry()` call. Subsequent steps and tests then silently inherited the previous run's overrides. A test that explicitly set `minTimeout=1000` poisoned the defaults for the rest of the session — later steps used 1000ms even when no `minTimeout` was passed. Pass a fresh `{}` as the first arg to `Object.assign` so a new merged options object is produced each call without touching the shared defaults. --- lib/recorder.js | 2 +- test/unit/recorder_test.js | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lib/recorder.js b/lib/recorder.js index 16e847f14..2f55ee093 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -217,7 +217,7 @@ export default { } const retryRules = this.retries.slice().reverse() - return promiseRetry(Object.assign(defaultRetryOptions, retryOpts), (retry, number) => { + return promiseRetry(Object.assign({}, defaultRetryOptions, retryOpts), (retry, number) => { if (number > 1) output.log(`${currentQueue()}Retrying... Attempt #${number}`) const [promise, timer] = getTimeoutPromise(timeout, taskName) return Promise.race([promise, Promise.resolve(res).then(fn)]) diff --git a/test/unit/recorder_test.js b/test/unit/recorder_test.js index ffb2b3629..4860de0e7 100644 --- a/test/unit/recorder_test.js +++ b/test/unit/recorder_test.js @@ -95,6 +95,54 @@ describe('Recorder', () => { return recorder.promise() }) + it('should not leak custom minTimeout to subsequent recorder runs', async function () { + // Regression: Object.assign(defaultRetryOptions, retryOpts) mutated the + // module-level defaultRetryOptions object. A custom minTimeout in one run + // leaked into the defaults for every later run. + this.timeout(5000) + + let attempts = [] + recorder.retry({ retries: 1, minTimeout: 800, factor: 1, maxTimeout: 1000 }) + recorder.add( + () => { + attempts.push(Date.now()) + if (attempts.length < 2) throw new Error('first run') + }, + undefined, + undefined, + true, + ) + try { + await recorder.promise() + } catch (e) { + await recorder.catchWithoutStop(err => err) + } + + expect(attempts[1] - attempts[0]).to.be.greaterThan(700, 'first retry should honor minTimeout=800') + + // Fresh recorder, do not pass minTimeout — should fall back to default (150ms), + // not 800 leaked from the previous run. + recorder.start() + attempts = [] + recorder.retry({ retries: 1, factor: 1, maxTimeout: 1000 }) + recorder.add( + () => { + attempts.push(Date.now()) + if (attempts.length < 2) throw new Error('second run') + }, + undefined, + undefined, + true, + ) + try { + await recorder.promise() + } catch (e) { + await recorder.catchWithoutStop(err => err) + } + + expect(attempts[1] - attempts[0]).to.be.lessThan(500, 'second retry must use default minTimeout, not leaked 800ms') + }) + it('should prefer opts for non-when retry when possible', () => { let counter = 0 const errorText = 'noerror'