fix(client): stop the user-token refresh from spinning - #1062
Open
adrewh wants to merge 1 commit into
Open
Conversation
`maybeScheduleUserTokenExpiration` computes its delay as `exp - timeBeforeExpirationInMs - now` and passes it straight to `setTimeout`. Whenever the client authenticates with a token that is already inside the refresh window that value is negative, `setTimeout` treats it as `0`, and the refresh runs on the next tick. If the refreshed token is also inside the window (a short-lived token, a stale token re-supplied by a re-render, a client whose clock runs ahead of the token issuer) the callback re-authenticates and the instance spins. Spinning is expensive rather than merely wasteful: `authenticate()` sees changed credentials, so every iteration tears down the API client and its socket, rebuilds them, re-identifies the user, and rejoins feed channels. Reproduced against this package with near-expiry tokens: 6,096 refreshes and 6,096 websocket opens in 10 seconds, which in a browser means a flood of 429s from `api.knock.app` and repeated "WebSocket is closed before the connection is established". Three changes: - Floor the timer delay at 1s so a token inside the refresh window schedules a real wait instead of an immediate refresh. - Clear the previous timer before scheduling. `authenticate()` with unchanged credentials does not tear down, so overwriting `tokenExpirationTimer` left the old timer live: it still woke up and called the app's refresh callback before failing the identity check. - Don't re-authenticate when the refreshed token expires no later than the token it replaces, or too soon to schedule another genuine wait. Log and stop, which leaves the socket in its normal reconnect backoff. Tokens with no readable expiration keep their current behaviour: they schedule nothing, so they cannot spin.
🦋 Changeset detectedLatest commit: 0b7a7da The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@adrewh is attempting to deploy a commit to the Knock Team on Vercel. A member of the Team first needs to authorize it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Knock.maybeScheduleUserTokenExpirationschedules its next refresh asexp - timeBeforeExpirationInMs - nowand passes the result straight tosetTimeout:Whenever the client authenticates with a token that is already inside the refresh window, that value is negative,
setTimeouttreats it as0, and the refresh callback runs on the next tick. If the token that comes back is also inside the window, the callback re-authenticates and the instance spins as fast as the app's token endpoint can answer.Spinning is expensive rather than merely wasteful.
authenticate()sees changed credentials on each iteration, so each one runsfeeds.teardownInstances()thenteardown()(socket disconnect),createApiClient(),feeds.reinitializeInstances(), plus an inlinePUT /v1/users/:idwhen identification is inline. Reproduced against this package under happy-dom with tokens that sit inside the refresh window (inert WebSocket stub counting opens):in 10 seconds. In a browser that is a flood of
429s fromapi.knock.appand repeatedWebSocket is closed before the connection is establishedas sockets are torn down mid-handshake. Node also surfaces the underlying mistake directly:We hit this in production behind a React integration: our
onUserTokenExpiringhandler mints a fresh 1h token every time and is nonetheless called ~6 times per second, sustained, from a single tab.Reaching the bad state does not require anything exotic: a token whose TTL is shorter than
timeBeforeExpirationInMs, a stale near-expiry token re-supplied by a re-render (useAuthenticatedKnockClientre-authenticates from theuserTokenprop, which an app that rotates in place never updates), a browser clock running ahead of the token issuer's, or a laptop resuming from sleep with a timer that should already have fired.Changes
MIN_TOKEN_EXPIRATION_DELAY_MS) so a token inside the refresh window schedules a real wait instead of an immediate refresh, andsetTimeoutnever receives a negative delay.authenticate()with unchanged credentials does not tear down, so overwritingtokenExpirationTimerleft the old timer live; it still woke up and called the app's refresh callback before failing thethis.tokenExpirationTimer !== timerIdcheck. A React effect re-running was enough to leak one wasted refresh call per re-auth.Tokens with no readable
expkeep their current behaviour (treated as progress) since they schedule nothing and so cannot spin.Tests
Four tests added to
test/knock.test.ts, in the existingJWT Token Expirationblock and style:yarn testis green across the monorepo (84 files, 1,015 tests), andyarn type:check,yarn lint, andyarn format:checkpass for@knocklabs/client. Patch changeset included.Notes
Happy to adjust the shape of this: the floor constant, whether the no-progress case should surface through something other than
log(..., true), or whether refusing to re-authenticate should be opt-in. The one behaviour I would argue for keeping either way is never scheduling a non-positive delay.