fix(infra): positive jitter 는 Math.ceil 로 non-integer retryAfterMs 하방도 보장 (cross-review)

This commit is contained in:
Feelw00
2026-04-19 01:21:55 +09:00
committed by Peter Steinberger
parent a77bd213ce
commit b7fc2451e7
2 changed files with 39 additions and 1 deletions

View File

@@ -109,6 +109,37 @@ describe("retryAsync respects server-supplied Retry-After as a lower bound", ()
expect(delays[0]).toBeLessThan(1_000);
});
it("does not round a non-integer retryAfterMs below its lower bound", async () => {
// Retry-After is typed as `number` (milliseconds), so non-integer values
// are legal. With `Math.round` in positive mode, `retryAfterMs=1.4` and
// `fraction=0` produce `Math.round(1.4 * 1) = 1`, which dips below the
// caller's lower bound. `Math.ceil` in positive mode closes that gap.
randomMocks.generateSecureFraction.mockReturnValue(0);
vi.useFakeTimers();
const delays: number[] = [];
const fn = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("429 non-integer retry-after"))
.mockResolvedValueOnce("ok");
const promise = retryAsync(fn, {
attempts: 2,
minDelayMs: 0,
maxDelayMs: 10,
jitter: 0.5,
retryAfterMs: () => 1.4,
onRetry: (info) => delays.push(info.delayMs),
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe("ok");
expect(delays).toHaveLength(1);
// Ceil-to-integer invariant: delay is at least the ceiling of the
// caller-supplied lower bound, never below it.
expect(delays[0]).toBeGreaterThanOrEqual(2);
});
it("never schedules a delay below retryAfterMs even at the low end of jitter", async () => {
// fraction = 0 is the adversarial case: symmetric jitter yields -jitter,
// i.e. delay = base * (1 - jitter), which would violate the Retry-After

View File

@@ -72,7 +72,14 @@ function applyJitter(delayMs: number, jitter: number, mode: JitterMode = "symmet
// below the caller's floor.
const fraction = generateSecureFraction();
const offset = mode === "positive" ? fraction * jitter : (fraction * 2 - 1) * jitter;
return Math.max(0, Math.round(delayMs * (1 + offset)));
const raw = delayMs * (1 + offset);
// Rounding choice preserves the mode's contract. `positive` guarantees
// `delay >= delayMs`, so a non-integer `delayMs` (e.g. retryAfterMs=1.4)
// must round *up* — plain `Math.round(1.4)=1` would drop the delay below
// the caller's lower bound and violate the Retry-After invariant the
// positive branch exists to enforce. Symmetric has no floor contract so
// it stays on `Math.round`.
return Math.max(0, mode === "positive" ? Math.ceil(raw) : Math.round(raw));
}
export async function retryAsync<T>(