diff --git a/src/infra/retry.retry-after-lower-bound.test.ts b/src/infra/retry.retry-after-lower-bound.test.ts index 7a831cb786b..b6bbb81996c 100644 --- a/src/infra/retry.retry-after-lower-bound.test.ts +++ b/src/infra/retry.retry-after-lower-bound.test.ts @@ -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>() + .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 diff --git a/src/infra/retry.ts b/src/infra/retry.ts index 366ad68bd3a..7dad706deaa 100644 --- a/src/infra/retry.ts +++ b/src/infra/retry.ts @@ -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(