Skip to content

fix: catch lock timeout errors instead of leaking to app#117

Open
nicknisi wants to merge 5 commits into
mainfrom
fix/lock-timeout-error-handling
Open

fix: catch lock timeout errors instead of leaking to app#117
nicknisi wants to merge 5 commits into
mainfrom
fix/lock-timeout-error-handling

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 13, 2026

Summary

  • After laptop sleep, AbortSignal.timeout(10s) on navigator.locks.request fires before the browser event loop resumes. The resulting LockError was escaping raw to the application because getAccessToken() only caught RefreshError.
  • Adds RefreshTimeoutError extends RefreshError — flows through existing catch blocks, distinguishable via instanceof for apps that need it.
  • When lock times out and the existing token is still valid, returns it instead of throwing. When expired or forceRefresh: true, throws RefreshTimeoutError (not LoginRequiredError, since the session is valid).
  • Fixes the same bug in switchToOrganization() — catches the timeout and warns instead of crashing.
  • Defensively catches both AbortError and TimeoutError DOMException names in withNativeLock.

Fixes the issue reported by VectorShift (same family as the Coast investigation from Oct 2024).

Test plan

  • getAccessToken() returns existing token when lock times out and token is unexpired
  • getAccessToken() throws RefreshTimeoutError when lock times out and token is expired
  • getAccessToken({ forceRefresh: true }) throws RefreshTimeoutError even with unexpired token
  • switchToOrganization() does not throw raw LockError — warns and resolves
  • All 82 existing tests pass (no regressions)
  • Type check clean (tsc --noEmit)
  • Build clean (tsup)

Open in Devin Review

nicknisi added 2 commits May 13, 2026 17:04
After laptop sleep, AbortSignal.timeout on navigator.locks.request
fires before the browser event loop resumes, causing a raw LockError
to escape to the application. This converts the error to a typed
RefreshTimeoutError (extends RefreshError) and applies an expiry-aware
fallback: return the existing token if still valid, throw
RefreshTimeoutError if expired or forceRefresh requested.

- Add RefreshTimeoutError class extending RefreshError
- Convert LockError to RefreshTimeoutError in #doRefresh
- Expiry-aware fallback in getAccessToken (return unexpired token)
- Handle RefreshTimeoutError in switchToOrganization (warn, don't redirect)
- Catch TimeoutError DOMException alongside AbortError in withNativeLock
- Export RefreshTimeoutError for consumers who need to catch it
@nicknisi nicknisi requested review from cmatheson and gjtorikian May 13, 2026 22:06
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

Comment thread src/create-client.ts
: undefined;
if (token) return token;
throw err;
} else if (err instanceof RefreshError) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like throwing a RefreshError is still going to result in the user being erroneously logged out? (i don't remember this codebase well maybe i'm wrong) The token in the example cited here is definitely expired (laptop has been suspended). What do you think about retrying N time(s) instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought about just silently catching. Retrying a few times and then throwing this more specific error might be best. Will update!

nicknisi added 3 commits May 13, 2026 17:27
- Remove unused LockError and MockWebLocks imports from tests
- Use native Error cause via super() instead of manual assignment
- Replace @ts-ignore with cast for navigator.locks cleanup
- Hoist installLockTimeout helper to shared scope, remove duplicate
When lock acquisition times out and the token is expired (or
forceRefresh requested), retry the refresh once before giving up.
This handles the common post-sleep case where the first attempt
times out but the lock clears on retry.
@nicknisi
Copy link
Copy Markdown
Member Author

Addressed @cmatheson's feedback — added a bounded retry (1 attempt) in 803683c. When the lock times out and the token is expired, we now retry the refresh once before throwing RefreshTimeoutError. This handles the common post-sleep case where the first attempt times out but the lock clears on the second try.

New test covers the retry-success path (first attempt times out, retry succeeds and returns a fresh token).

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