Skip to content

fix: Preserve session on transient refresh failures#461

Merged
m0tzy merged 2 commits into
mainfrom
devin/1784833542-preserve-session-transient-refresh
Jul 23, 2026
Merged

fix: Preserve session on transient refresh failures#461
m0tzy merged 2 commits into
mainfrom
devin/1784833542-preserve-session-transient-refresh

Conversation

@m0tzy

@m0tzy m0tzy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

When the access token is already invalid/expired and a refresh is required, updateSession caught any error from authenticateWithRefreshToken and deleted the session cookie + 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). The proactive/isExpiring path was already resilient, but only while the current token was still valid.

This classifies the failure and only tears down the session for a terminal error. On a transient failure the sealed cookie is kept, so a later request refreshes successfully once the condition clears.

// session.ts — reactive refresh catch
const isTransient = isTransientRefreshError(e);
if (!isTransient) {
  // delete wos-session cookie (+ jwt cookie) and redirect to sign-in  ← unchanged terminal behavior
}
options.onSessionRefreshError?.({ error: e, request });
// transient: cookie left intact; request still returns unauthenticated for now

Classification mirrors the SDK's own retry rules — a network failure surfaces as a TypeError from the fetch client once retries are exhausted; transient HTTP responses carry a retryable numeric status:

const RETRYABLE_REFRESH_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);

export function isTransientRefreshError(error: unknown): boolean {
  if (error instanceof TypeError) return true; // network / DNS / connection reset
  if (typeof error === 'object' && error !== null && 'status' in error) {
    const { status } = error;
    return typeof status === 'number' && RETRYABLE_REFRESH_STATUS_CODES.has(status);
  }
  return false; // terminal: invalid_grant (400), 401, or unrecognized
}

refreshSession() (the explicit helper) still throws TokenRefreshError, now carrying an isTransient flag so callers can keep the session and retry instead of signing the user out.

Terminal behavior is unchanged: invalid_grant/401/unrecognized errors still clear the cookie and redirect.

Test plan

  • pnpm test (405 tests pass), pnpm typecheck, pnpm lint all green.
  • New session.spec.ts coverage: parametrized transient cases (429, 503, 408, network TypeError) assert the wos-session cookie is not cleared; a terminal invalid_grant (400) case asserts the cookie is cleared.

Part of the AuthKit refresh-token DX work (server returns 429 for transient refresh lock timeouts in workos/workos#66577; typed transient/terminal session.refresh() in workos/workos-node#1663).

Link to Devin session: https://app.devin.ai/sessions/fc39103abf694f90b1c92dd714a81461
Requested by: @m0tzy

The reactive refresh path deleted the session cookie and redirected to
sign-in on any failure from authenticateWithRefreshToken, including
transient ones (network error, request timeout, 429, or 5xx) that
survived the SDK's internal retries. During a brief outage this turned a
still-valid refresh token into a forced re-authentication.

Classify the failure and only clear the cookie for a terminal error.
On a transient failure keep the sealed session so a later request
refreshes successfully once the condition clears. Also expose an
isTransient flag on TokenRefreshError for callers of refreshSession().

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@m0tzy
m0tzy requested a review from a team as a code owner July 23, 2026 19:06
@m0tzy m0tzy self-assigned this Jul 23, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor
Original prompt from madison.packer

SYSTEM:
=== BEGIN THREAD HISTORY (in #team-authkit) ===
<most_recent_message>
Madison Packer (U0ACAL99VSL): @Devin can you investigate the refresh token feedback here and plan for how we might improve the DX <https://work-os.slack.com/archives/C0APCBRV47Q/p1784811878194419|https://work-os.slack.com/archives/C0APCBRV47Q/p1784811878194419>
</most_recent_message>
=== END THREAD HISTORY ===

Thread URL: https://work-os.slack.com/archives/C0173N0DDSQ/p1784817744204199?thread_ts=1784817744.204199&amp;cid=C0173N0DDSQ

The latest message is the one right above that tagged you. The <most_recent_message> is the message that you should use to guide your goals + task for this session, and you should use the rest of the slack thread as context.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot changed the title Preserve session on transient refresh failures fix: Preserve session on transient refresh failures Jul 23, 2026
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds resilience to session refresh failures by distinguishing transient errors (network failures, 429, 5xx) from terminal ones (invalid_grant, 401). Previously, any error from authenticateWithRefreshToken would delete the session cookie and redirect to sign-in; now the sealed cookie is preserved on transient failures so the next request can retry once the condition clears.

  • isTransientRefreshError is a new exported classifier that checks for retryable HTTP status codes or network-origin TypeErrors (with cause-chain traversal to handle SDK-wrapped errors).
  • TokenRefreshError gains an isTransient: boolean property, and the onSessionRefreshError callback now includes isTransient in its payload, giving callers the information needed to decide whether to tear down local state.
  • Test coverage is added for parametrised transient cases (429, 503, 408, network TypeError, SDK-wrapped TypeError) and a terminal case (invalid_grant/400).

Confidence Score: 5/5

Safe to merge. The classification logic is correct, terminal behaviour is unchanged, and the new tests cover the key transient/terminal boundary.

The core change — preserving the session cookie on transient refresh failures while still clearing it on terminal ones — is implemented correctly and is well-tested. The isTransientRefreshError classifier mirrors the SDK's own retry rules, TokenRefreshError and onSessionRefreshError both expose isTransient for callers, and the parametrised test suite validates cookie preservation for 429/503/408/network TypeError/SDK-wrapped TypeError as well as cookie deletion for invalid_grant. Two minor observations were flagged but neither affects correctness.

No files require special attention. The regex in isNetworkError and the unconditional PKCE-cookie generation on the transient path are worth a follow-up, but they do not affect the session-preservation behaviour that is the focus of this PR.

Important Files Changed

Filename Overview
src/session.ts Core logic change: transient-vs-terminal classification added to the reactive refresh catch block, with session cookie deletion and JWT cookie deletion gated on !isTransient. The new isTransientRefreshError / isNetworkError helpers are well-structured with cause-chain recursion. onSessionRefreshError now receives isTransient; refreshSession propagates it through TokenRefreshError.
src/errors.ts Adds isTransient?: boolean to TokenRefreshErrorContext and readonly isTransient: boolean to TokenRefreshError, defaulting to false when not provided. Clean, minimal change.
src/interfaces.ts Adds the required isTransient: boolean field to the onSessionRefreshError callback payload, addressing the previously flagged gap. Breaking change in the type signature that callers may need to update.
src/session.spec.ts Adds a parametrised it.each for five transient error shapes and a terminal invalid_grant control case. Existing onSessionRefreshError test updated to assert isTransient: false. Coverage is solid for the new classification logic.

Reviews (2): Last reviewed commit: "Follow cause chain for network errors an..." | Re-trigger Greptile

Comment thread src/session.ts Outdated
Comment thread src/session.ts Outdated
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@m0tzy
m0tzy merged commit 07b8dfa into main Jul 23, 2026
5 checks passed
@m0tzy
m0tzy deleted the devin/1784833542-preserve-session-transient-refresh branch July 23, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants