Skip to content

fix(admin): floor the silent-refresh delay so it can never hot-loop (#323) - #329

Merged
jayesh-keychain merged 1 commit into
mainfrom
fix/323-admin-refresh-loop
Aug 1, 2026
Merged

fix(admin): floor the silent-refresh delay so it can never hot-loop (#323)#329
jayesh-keychain merged 1 commit into
mainfrom
fix/323-admin-refresh-loop

Conversation

@jayesh-keychain

Copy link
Copy Markdown
Collaborator

msUntilRefresh clamped to 0:

Math.max(0, exp * 1000 - skewSeconds * 1000 - nowMs)

Whenever a token's remaining life was shorter than the 60s skew that returned 0, the scheduler called setTimeout(…, 0), the refresh minted another equally short-lived token, and the next delay was 0 again. Not a retry — an unbounded tight loop: ~180 req/s, 32,915 calls to /auth/refresh in a few minutes, every one returning 200, churning a refresh-token row each time via single-use rotation.

Fix — two changes, both about never scheduling zero:

  • MIN_REFRESH_DELAY_MS floor (5s) — a pathological case becomes slow rather than hot, whatever the arithmetic does.
  • The skew adapts: min(60s, remainingLife / 2). Reserving a fixed 60s of a 15s token marks every refresh permanently overdue, which is what starts the loop.
TTL next refresh
900s (production) 840s — unchanged
60s 30s
30s 15s
15s 7.5s (was: 0, looping)

Correction to the issue as filed

#323 claimed this bites in production ~15 minutes after sign-in at JWT_ACCESS_TTL=900. That is wrong. At 900s the scheduler fires once at 840s and reschedules normally — no loop. It requires a TTL at or below the 60s skew, which I had set myself while testing #318. Still a real robustness defect, but not the production incident described — issue corrected and downgraded P0 → P2.

Verification

  • 13 tests pass, incl. 5 new regression tests: the loop condition, adaptive skew, expired token, unparseable token, and production delay unchanged
  • An existing test asserted toBe(0) for a 30s token — it encoded the buggy contract, so it is updated, not worked around
  • type-check + lint green

Closes #323.

🤖 Generated with Claude Code

…323)

`msUntilRefresh` clamped to 0:

    Math.max(0, exp * 1000 - skewSeconds * 1000 - nowMs)

Whenever a token's remaining life was shorter than the 60s skew, that returned
0, the scheduler called `setTimeout(…, 0)`, the refresh minted another equally
short-lived token, and the next delay was 0 again. Not a retry — an unbounded
tight loop. Measured against a dev stack with JWT_ACCESS_TTL=15: ~180
requests/second, 32,915 calls to /auth/refresh in a few minutes, every one
returning 200, and single-use rotation churning a refresh-token row each time.

Two changes, both about never scheduling zero:

  - MIN_REFRESH_DELAY_MS floor (5s). A pathological case is now slow instead of
    hot, whatever the arithmetic does.
  - The skew ADAPTS: `min(60s, remainingLife / 2)`. Reserving a fixed 60s of a
    15s token is meaningless — it marks every refresh permanently overdue, which
    is what starts the loop. Half the remaining life always leaves real time on
    the clock.

Production timing is UNCHANGED: a 900s token still refreshes at 840s. Only
lifetimes at or under the skew window move.

CORRECTION to the issue as filed: #323 claimed this bites in production ~15
minutes after sign-in at JWT_ACCESS_TTL=900. That is wrong. At 900s the
scheduler fires once at 840s and reschedules normally — no loop. The loop
requires a TTL at or below the 60s skew, which I had set myself while testing
#318. It is a real robustness defect (any short TTL, clock skew, or a token
already near expiry self-DoSes) but NOT the production incident described. The
issue is being corrected and downgraded from P0.

An existing test asserted `toBe(0)` for a 30s token — it encoded the buggy
contract, so it is updated rather than worked around: that token now refreshes
at 15s. Five regression tests cover the loop condition, the adaptive skew, an
expired token and an unparseable one. 13 tests pass; type-check and lint green.

Closes #323.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jayesh-keychain
jayesh-keychain merged commit 7539d4f into main Aug 1, 2026
6 checks passed
@jayesh-keychain
jayesh-keychain deleted the fix/323-admin-refresh-loop branch August 1, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(admin): refresh loop hammers /auth/refresh at ~180 req/s once the token expires

1 participant