fix: Preserve session on transient refresh failures - #88
Conversation
Original prompt from madison.packer
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| export function isTransientRefreshError(error: unknown): boolean { | ||
| if (error instanceof TypeError) { | ||
| return true; | ||
| } | ||
|
|
||
| if (typeof error === 'object' && error !== null && 'status' in error) { | ||
| const { status } = error; | ||
| return typeof status === 'number' && RETRYABLE_REFRESH_STATUS_CODES.has(status); | ||
| } | ||
|
|
||
| return false; | ||
| } |
There was a problem hiding this comment.
🔍 Transient classification depends on WorkOS SDK error shape
isTransientRefreshError (src/session.ts:58-69) relies on two assumptions about what authenticateWithRefreshToken throws after the SDK exhausts its internal retries: (1) network-level failures surface as a raw TypeError, and (2) transient HTTP responses expose a numeric status property (not statusCode) with values in {408,429,500,502,503,504}. If the WorkOS SDK wraps network errors in its own exception class (rather than letting a TypeError propagate) or exposes the code under a different property name, transient failures would be misclassified as terminal and the session would still be destroyed. This could not be verified from the repo since @workos-inc/node was not installed in node_modules. Worth confirming against the SDK's actual error contract.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good flag — I verified against workos-node's actual error contract and it exposed a real gap. Two findings:
- Transient HTTP responses do carry a numeric
status: the SDK maps them toOauthException/GenericServerException/RateLimitExceededException, all of which set a numericstatus(notstatusCode), and request timeouts are normalized to408by the fetch client. So the status-based branch is correct. - Network failures do not reach the caller as a
TypeError. The fetch client throws a rawTypeError, butWorkOS.handleHttpErrorre-wraps anything that isn't anHttpClientErrorinnew Error('Unexpected error: ...', { cause: originalTypeError }). So the bareinstanceof TypeErrorcheck was effectively dead for real SDK usage.
Fixed in the latest commit: the classifier now follows the cause chain and matches a TypeError only when its message looks like a network failure. Added a test for the SDK-wrapped shape.
Greptile SummaryThis PR fixes a session durability bug where any
Confidence Score: 5/5Safe to merge. The change is well-scoped, thoroughly tested, and the session-preservation path is additive — it touches no existing terminal-error logic. The transient/terminal split is correctly placed after the onSessionRefreshError callback, tests exercise every code path (transient statuses, SDK-wrapped TypeError, terminal invalid_grant), and the isNetworkError cause-chain follower is correctly guarded against infinite recursion. No pre-existing behavior is removed or weakened. No files require special attention. Important Files Changed
|
|
Re: the "Comments Outside Diff" note that |
|
Re: the coverage note that |
|
This looks good but should it be on workos/authkit-react-router instead/too? |
authkitLoader destroyed the sealed session and redirected to sign-in on any SessionRefreshError, including transient failures (network error, request timeout, 429, or 5xx) that survived the SDK's internal retries. During a brief outage this discarded a still-valid refresh token and forced re-authentication. Classify the wrapped refresh error and only destroy the session for a terminal failure. On a transient failure keep the sealed cookie so a later request refreshes successfully once the condition clears. SessionRefreshError now exposes an isTransient flag for callers. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
8ba5c7b to
2c5bc40
Compare
Summary
authkitLoadercaught anySessionRefreshErrorand destroyed the sealed session + redirected to sign-in. That includes transient failures (network error, request timeout,429,5xx) that survive the SDK's internal retries — so a brief outage discards a still-valid refresh token and forces re-authentication (the BaseTen lockout pattern).This classifies the wrapped refresh error and only destroys the session for a terminal failure. On a transient failure the sealed cookie is kept, so a later request refreshes successfully once the condition clears.
SessionRefreshErrornow carries anisTransientflag derived from the wrapped cause, using the SDK's own retry classification — a network failure surfaces as aTypeError; transient HTTP responses carry a retryable numericstatus:onSessionRefreshErrorstill runs for both cases, so consumers can override behavior. Terminalinvalid_grant/401/unrecognized errors still destroy the cookie and redirect.Test plan
npm test(95 tests pass),npm run lint,prettier --check, andtsc --noEmitall green.session.spec.tscoverage: parametrized transient cases (429,503,408, networkTypeError) assertdestroySessionis not called and noSet-Cookieis emitted; a terminalinvalid_grant(400) case asserts the session is destroyed.Part of the AuthKit refresh-token DX work (server returns
429for transient refresh lock timeouts in workos/workos#66577; typed transient/terminalsession.refresh()in workos/workos-node#1663; matching fix for Next.js in workos/authkit-nextjs#461).Link to Devin session: https://app.devin.ai/sessions/fc39103abf694f90b1c92dd714a81461
Requested by: @m0tzy