Files
vercel__next.js/test/unit/next-test-utils-retry.test.ts
dan be1424f3dc Make retry stop when the time it was given is up (#96354)
How long do you expect this to wait at worst (before failing)?

```js
await retry(async () => {
  expect(await browser.elementById('never-appears').text()).toBe('x')
}, 15_000)
```

After this PR, the answer is 15 seconds. (Well, technically, 15 seconds
+ whatever last attempt is, so 20.)

But before this PR, the answer was 3 minutes.

## What?

There's a bug (IMO) in `retry`. Instead of waiting for the time you give
it, it counts tries.

For example, `retry(fn, 15_000)` reads as "keep trying for 15 seconds".
However, what it actually was doing is divide 15000 by the 500ms
interval and try 30 times, sleeping 500ms in between every try.

This is a problem because **it wasn't measuring the stall time of each
try itself.**

When a try is cheap, it doesn't matter. But a try that looks for an
element in the browser isn't cheap: if the element isn't there, the
selector waits 5 seconds before giving up.

In the example above, each try sits in the selector for its full 5
seconds, and there are 31 of these intervals, plus the 15 seconds of
sleeping in between. This ends up waiting 170103ms, i.e. three minutes
for a wait that says 15 seconds.

Jest kills the test at its own timeout long before that, and the actual
error gets thrown away. What's left is `Exceeded timeout of 120000 ms`,
which doesn't say which assertion never came true. In the one I measured
it was `page.waitForSelector: Timeout 5000ms exceeded`. My agent lost an
hour trying to make sense of this twice now.

## Fix

We count time now. After the fix, the retry watches the clock and stops
when its time is up. It still always makes at least one try. The
interval no longer has to divide the duration, because nothing counts
steps any more. If the last try is slow, we're gonna wait for that last
try to end (so the timeout is still not *strictly* respected) so that we
can show the error in the correct test.

Cheap waits behave exactly as before. In my local run, a try that had to
repeat cost 251ms at the median, 459ms at the worst. So the only calls
this changes are ones that were already failing, and those now fail with
their own error instead of a test timeout. One flaky dev test that used
to die at 120 seconds with nothing to show now fails in 19.5 seconds
saying `Failed to retry within 15000ms (waited 16022ms)`, next to nine
tests that take 2.2 seconds each.

I added some regression tests. Since it's a test helper, a unit test
seemed fine.

## What this means for existing tests

The catch is that a call whose tries are slow now gets less time than it
used to, and this helper has 2110 call sites, so CI is the real check.
It found one file: the hash scroll tests in `navigation.test.ts` wait
with the default 3 seconds, and each try there costs 150-300ms on a
runner, so they were really getting about 5. They need more than 3, so
they failed on two dev shards. I gave them 10.

If this is green, we can merge it in isolation from the rest of the
stack.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:42:56 +01:00

73 lines
1.6 KiB
TypeScript

import { retry, waitFor } from 'next-test-utils'
describe('retry', () => {
it('returns the first successful result', async () => {
let attempts = 0
const result = await retry(
() => {
attempts++
if (attempts < 3) {
throw new Error('not yet')
}
return 'done'
},
3000,
10
)
expect(result).toBe('done')
expect(attempts).toBe(3)
})
it('stops once the duration is spent when attempts are slower than the interval', async () => {
const started = Date.now()
let attempts = 0
await expect(
retry(
async () => {
attempts++
await waitFor(1000)
throw new Error('never succeeds')
},
2000,
500
)
).rejects.toThrow('never succeeds')
const elapsed = Date.now() - started
expect(elapsed).toBeLessThan(4000)
expect(attempts).toBeLessThanOrEqual(3)
})
it('takes an interval that does not divide the duration', async () => {
await expect(
retry(
() => {
throw new Error('nope')
},
1000,
300
)
).rejects.toThrow('nope')
})
it('rejects a negative duration', async () => {
await expect(retry(() => 'done', -1000)).rejects.toThrow(
'Duration cannot be less than 0.'
)
})
it('always makes at least one attempt', async () => {
let attempts = 0
await expect(
retry(
() => {
attempts++
throw new Error('nope')
},
0,
500
)
).rejects.toThrow('nope')
expect(attempts).toBe(1)
})
})