Skip to content

Flaky under full-suite load: two more caller-abort tests race a timer, the shape #655 thought it had cleared #783

Description

@jeremy

Observed once during a full LC_ALL=C make ts-check run while verifying #781. Unrelated to that PR — see "Why it is not the change under test" below — but it is a latent red that will flake in CI on somebody else's branch.

The failure

typescript/tests/middleware-lifecycle.test.ts:269"rejects promptly when the caller aborts during a retry backoff".

FAIL tests/middleware-lifecycle.test.ts > middleware request lifecycle
     > rejects promptly when the caller aborts during a retry backoff
AssertionError: expected undefined to be Error: caller cancelled during backoff
- Expected: Error { "message": "caller cancelled during backoff" }
+ Received: undefined
  ❯ tests/middleware-lifecycle.test.ts:297:19

undefined is the resolve branch of .then(() => undefined, (e) => e), so the request completed instead of rejecting: the 100 ms abort timer did not land inside the backoff window it is aiming at.

Cleared 3/3 on the single test in isolation, 2/2 on the whole file, and the full suite passed on an immediate re-run (87 files / 1654 tests). So it is timing-sensitive, not broken — which, as #655 put it, is the worst kind of red: it fails rarely, on an unrelated PR, and costs someone an hour deciding whether they caused it.

This is #655's shape, and #655's survey missed it

#655 fixed exactly this race in client.test.ts — a short abort timer against a longer mocked response, plus a redundant wall-clock assertion — and scoped itself with:

It is the only test in typescript/tests/ with this shape (rg "setTimeout\(\(\) => controller.abort\(\), [0-9]+\)" returns one hit), so the fix is contained.

That was already false when it was written. The selector requires a no-argument abort(), and both surviving instances pass a reason:

$ rg -n 'setTimeout\(\(\) => controller\.abort\(\), [0-9]+\)' typescript/tests | wc -l
0
$ rg -n 'setTimeout\(\(\) => controller\.abort\([^)]*\), [0-9]+\)' typescript/tests
typescript/tests/middleware-lifecycle.test.ts:244:    const abortTimer = setTimeout(() => controller.abort(reason), 50);
typescript/tests/middleware-lifecycle.test.ts:286:    const abortTimer = setTimeout(() => controller.abort(reason), 100);

Both landed in #538 (4ef2c909f, 2026-07-31), a fortnight before #655 was filed. One regex character class is the whole reason they were not in scope.

Line 244 is not merely the same species, it is the same race: a 50 ms abort timer against a 1000 ms mocked response, which is #655's own numbers exactly. It has not been seen red yet; it is one scheduling hiccup from it.

Line 286 (the one that failed) is a variant: it races the 100 ms timer against a 2 s Retry-After backoff, and pairs it with expect(Date.now() - startedAt).toBeLessThan(1000) — the same redundant wall-clock assertion #655 identified as contributing all of the load sensitivity and none of the discriminating power.

So this is not "a third flaky test to widen". It is the second and third instances of one shape whose remedy is already written down.

The remedy is #655's option 1, and there is a worked example in-tree

Abort deterministically instead of on a timer. Call controller.abort() after the request is in flight … so no wall-clock margin is involved at all.

#781 needed the same thing for a different reason — asserting a three-minute server-directed delay without waiting it out — and used exactly that idiom: it aborts from inside the retrying lifecycle hook, which the loop fires immediately before it sleeps.

retrying: (_failedAttempt, _error, delayMs) => {
  chosen = delayMs;
  controller.abort(new Error("delay captured"));
},

That is deterministic — no margin, no load sensitivity — and it settles in ~1 ms rather than 100. typescript/tests/retry-after.test.ts is the worked example; both tests here have the same hooks available.

Sketch for line 286: abort from hooks.onRetry instead of a timer, keep expect(err).toBe(reason) and the start/end attempt assertions, and drop toBeLessThan(1000)err === reason already proves the caller's signal won, which is the property under test. Line 244 wants the same treatment against onRequestStart.

Why it is not the change under test

#781 changes parseRetryAfter. This test sends Retry-After: 2, which the new parser handles identically to the old one: ^[+-]?\d+$ matches, Number("2") is 2, > 0, returned — same value, same code path, no new work. The test also passed 3/3 in isolation and 2/2 for the whole file against the changed parser, and the full suite was green on re-run.

What #781 plausibly did do is expose it. It added ~3 s of real sleeps to two sibling files (account-logo.test.ts and retry-after.test.ts, which now assert genuine backoff intervals where a defect previously collapsed them to zero), which shifts vitest's worker scheduling and changes what else is competing for the event loop when this file runs. That is worth recording for whoever picks this up: the trigger is load and interleaving, so the reproduction is a full-suite run, not this file.

Deliberately not fixed in #781 — it is someone else's test and hardening it there would widen a parsing fix into a de-flaking one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions