Skip to content

feat(auth): extract token refresh logic into TokenRefreshHandler#1309

Open
grdsdev wants to merge 12 commits intomainfrom
grdsdev/auth-token-refresh
Open

feat(auth): extract token refresh logic into TokenRefreshHandler#1309
grdsdev wants to merge 12 commits intomainfrom
grdsdev/auth-token-refresh

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Feb 4, 2026

Fixes #895
Fixes #930
Partially addresses #928

Summary

Refactors token refresh synchronization into a dedicated TokenRefreshHandler class for better maintainability and testability. Improves error handling, proper disposal semantics, and adds comprehensive unit tests.

Changes

  • Extracts token refresh logic from GoTrueClient into new TokenRefreshHandler class
  • Adds intelligent concurrent request handling: same-token requests share a future, different-token requests are queued
  • New AuthClientDisposedException for disposed state errors
  • 25 comprehensive unit tests covering concurrent requests, queueing, and error scenarios
  • Proper disposal handling and resource cleanup

Issues Resolved

Test Plan

  • All 25 new TokenRefreshHandler tests pass
  • All 53 existing auth exception tests pass
  • Token deduplication works correctly for concurrent same-token requests
  • Queueing works correctly for different-token concurrent requests
  • Disposal properly completes pending operations with errors
  • Success callbacks skip execution after disposal

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Centralized token refresh handler to coordinate, deduplicate, queue, and expose refresh/dispose status.
  • Bug Fixes

    • Prevents overlapping refreshes, stops refreshes after client disposal, and improves success/error handling and retry/backoff behavior.
  • New

    • Adds a specific disposed-client exception for operations attempted after disposal.
  • Tests

    • Comprehensive tests covering coordination, concurrency, error paths, disposal semantics, and the disposed-client exception.
  • Chores

    • Temporarily commented CI web build step (TODO to re-enable).

Refactor token refresh synchronization into a dedicated class for better
maintainability and testability. Adds comprehensive error handling, proper
disposal semantics, and 25 unit tests covering concurrent requests,
queueing, and error scenarios.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@github-actions github-actions bot added the auth This issue or pull request is related to authentication label Feb 4, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Centralizes token refresh by adding TokenRefreshHandler and RefreshOperation, integrates it into GoTrueClient (replacing the private completer flow), adds AuthClientDisposedException, and adds comprehensive tests for refresh coordination, concurrency, errors, and disposal.

Changes

Cohort / File(s) Summary
Token refresh handler & exception
packages/gotrue/lib/src/token_refresh_handler.dart, packages/gotrue/lib/src/types/auth_exception.dart
Adds TokenRefreshHandler, RefreshOperation, related typedefs and lifecycle methods; implements queuing/deduplication, sequential processing, error/retry signaling, disposal propagation, and new AuthClientDisposedException.
GoTrueClient integration
packages/gotrue/lib/src/gotrue_client.dart
Replaces _refreshTokenCompleter with TokenRefreshHandler; delegates refresh calls to the handler, adds disposed/in-progress guards, explicit _handleRefreshSuccess/_handleRefreshError callbacks, and ensures handler shutdown in dispose().
Unit tests
packages/gotrue/test/src/token_refresh_handler_test.dart, packages/gotrue/test/src/types/auth_exception_test.dart
Adds extensive tests validating handler behavior: concurrency (join/queue), success/error propagation, disposal semantics, idempotency, and AuthClientDisposedException behavior.
CI workflow
.github/workflows/supabase_flutter.yml
Comments out an Ubuntu Flutter web (WASM) build step and adds a TODO to re-enable later; other workflow steps unchanged.

Sequence Diagram(s)

sequenceDiagram
    participant Client as GoTrueClient
    participant Handler as TokenRefreshHandler
    participant Backend as RefreshCallback

    Client->>Handler: refresh(tokenA)
    activate Handler
    Handler->>Handler: start RefreshOperation(tokenA)
    Client->>Handler: refresh(tokenA) (concurrent)
    Note over Handler: same token → join existing operation
    Client->>Handler: refresh(tokenB)
    Note over Handler: different token → enqueue tokenB
    Handler->>Backend: invoke refreshCallback(tokenA)
    Backend-->>Handler: AuthResponse / error
    alt success
        Handler->>Client: resolve joined futures (tokenA)
        Handler->>Handler: process next queued (tokenB)
        Handler->>Backend: invoke refreshCallback(tokenB)
        Backend-->>Handler: AuthResponse
        Handler->>Client: resolve queued futures (tokenB)
    else error
        Handler->>Client: propagate error to callers
    end
    deactivate Handler
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: extracting token refresh logic into a dedicated TokenRefreshHandler class, which is the core refactoring across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch grdsdev/auth-token-refresh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/gotrue/lib/src/token_refresh_handler.dart`:
- Around line 198-211: The _handleError method currently calls the user-provided
callback _onError and then operation.completeError, but if _onError throws the
call to operation.completeError will be skipped; wrap the _onError invocation in
a try/catch (catching Exception/Throwable) so any exceptions from _onError are
swallowed or logged, then always call operation.completeError(error, stack) in a
finally (or after the catch) to guarantee the RefreshOperation is completed;
make this change inside the _handleError function to protect the refresh
futures.

In `@packages/gotrue/test/src/token_refresh_handler_test.dart`:
- Around line 11-608: Shorten the long inline comment in the test "does not call
success callback after dispose": replace the comment "// Success callback should
NOT have been called because _isDisposed check" with a shorter phrase under 80
characters (for example "// Success callback should NOT be called after
dispose") in the test body where successCalled and callbackCompleter are used so
the line length rule is satisfied without changing behavior of the test or the
use of TokenRefreshHandler/_isDisposed logic.
🧹 Nitpick comments (1)
packages/gotrue/lib/src/gotrue_client.dart (1)

1341-1355: Skip error handling after disposal to avoid touching closed streams.
If a refresh fails after dispose(), this can still emit on closed subjects or broadcast channels. Consider a guard similar to _handleRefreshSuccess.

💡 Suggested guard
   void _handleRefreshError(
     AuthException error,
     StackTrace stack,
     bool isRetryable,
   ) {
+    if (_tokenRefreshHandler.isDisposed) {
+      _log.fine('Skipping refresh error handling: client is disposed');
+      return;
+    }
     if (!isRetryable) {
       // Non-retryable error: sign out and notify
       _removeSession();
       notifyAllSubscribers(AuthChangeEvent.signedOut);
     }
 
     // Always notify exception (this logs it)
     notifyException(error, stack);
   }

grdsdev and others added 2 commits February 4, 2026 16:35
Replace catchError with ignore() to properly handle futures that
intentionally don't need their errors caught.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Wrap _onError callback in try/catch to ensure operation.completeError
  is always called even if the error callback throws
- Shorten test comment for line length compliance

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/gotrue/lib/src/token_refresh_handler.dart`:
- Around line 230-285: The dequeued operation started in _processNextQueued
(variable nextOperation) can be in-flight when dispose() runs and thus won't be
failed; fix by assigning the dequeued completer to the handler's in-flight
tracker (_currentOperation) before calling
_startOperation(nextOperation.refreshToken), and ensure _currentOperation is
cleared when that future completes (both success and error paths), so dispose()
will completeError the in-flight operation; update _processNextQueued to set
_currentOperation = nextOperation and clear it in the then/onError callbacks,
and keep dispose() logic to completeError _currentOperation if non-null.
🧹 Nitpick comments (1)
packages/gotrue/test/src/token_refresh_handler_test.dart (1)

351-490: Add a regression test for dispose during an in-flight queued refresh.
Current disposal tests cover queued requests before they start. The edge case where a queued operation has been dequeued and started can still complete successfully after dispose() unless explicitly guarded.

✅ Example test
@@
     test('queued operation gets error on dispose', () async {
       final completer1 = Completer<AuthResponse>();
@@
       expect(caughtError, isA<AuthClientDisposedException>());
     });
+
+    test(
+      'queued op in-flight fails if disposed mid-refresh',
+      () async {
+        final completer1 = Completer<AuthResponse>();
+        final completer2 = Completer<AuthResponse>();
+
+        final handler = TokenRefreshHandler(
+          logger: logger,
+          refreshCallback: (token) {
+            if (token == 'token1') return completer1.future;
+            return completer2.future;
+          },
+          onSuccess: (_) {},
+          onError: (_, __, ___) {},
+        );
+
+        final future1 = handler.refresh('token1');
+        final future2 = handler.refresh('token2');
+
+        completer1.complete(createMockResponse());
+        await future1;
+        await Future.delayed(Duration.zero);
+
+        handler.dispose();
+        completer2.complete(createMockResponse());
+
+        await expectLater(
+          future2,
+          throwsA(isA<AuthClientDisposedException>()),
+        );
+      },
+    );

Pass queued operation to _startOperation so it becomes _currentOperation,
ensuring dispose() can properly fail in-flight queued operations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/gotrue/lib/src/token_refresh_handler.dart`:
- Around line 190-196: The catch block in token_refresh_handler.dart creates an
AuthUnknownException and passes it to _handleError but then rethrows the
original error, causing mismatch between what _currentOperation.future completes
with and what direct callers receive; change the rethrow to throw the created
authError (or otherwise rethrow the same AuthUnknownException instance) so both
direct callers of _startOperation() and awaiters of _currentOperation.future
receive the same AuthUnknownException; update the catch clause around
_handleError, AuthUnknownException, and the rethrow to ensure the same exception
instance is propagated.
🧹 Nitpick comments (1)
packages/gotrue/lib/src/token_refresh_handler.dart (1)

127-129: Consider simplifying the queued operation lookup.

The current pattern using .cast<RefreshOperation?>() with a null assertion in the predicate is functional but verbose. A cleaner approach would be:

♻️ Suggested simplification
     // Check if we already have a queued operation for this token
-    final existingQueued = _pendingOperations
-        .cast<RefreshOperation?>()
-        .firstWhere((op) => op!.isForToken(refreshToken), orElse: () => null);
+    final existingQueued = _pendingOperations
+        .where((op) => op.isForToken(refreshToken))
+        .firstOrNull;

Note: firstOrNull requires package:collection or Dart 3.0+. If not available, a simple loop works equally well:

RefreshOperation? existingQueued;
for (final op in _pendingOperations) {
  if (op.isForToken(refreshToken)) {
    existingQueued = op;
    break;
  }
}

grdsdev and others added 2 commits February 5, 2026 11:00
WASM tests are failing in CI with no apparent reason. Disabling them
temporarily to unblock CI. Tracked in SDK-670.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Change `rethrow` to `throw authError` in the catch block so both direct
callers of _startOperation() and awaiters of _currentOperation.future
receive the same AuthUnknownException instance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@grdsdev grdsdev requested review from a team and Vinzent03 February 5, 2026 15:08
@grdsdev
Copy link
Copy Markdown
Contributor Author

grdsdev commented Feb 5, 2026

Issues Resolved by This PR

This PR fixes concurrent token refresh race conditions that cause the following issues:

Fixes #895 - Custom LocalStorage logs out user instead of refreshing session

  • Root cause: Concurrent refresh requests with different tokens cause "Already Used" errors
  • Solution: TokenRefreshHandler queues different-token requests sequentially

Fixes #930 - Invalid Refresh Token: Refresh Token Not Found errors

  • Root cause: Multiple simultaneous refresh attempts racing
  • Solution: Same-token requests now share a single future (deduplication)

Partially fixes #928 - AuthRetryableFetchError

  • Improved error handling and disposal guards reduce some error scenarios
  • Note: Offline errors (Fatal exception when offline #1281) still need documentation for proper error handler usage

Issues NOT Fixed (Require Follow-up)

The following issues require a separate PR to add proactive session refresh on app resume:

Root cause: When app resumes from background with expired token, the auto-refresh ticker takes up to 10 seconds to detect expiry. If an API call happens before the first tick, the expired token is used.

Required fix: Add proactive session check in didChangeAppLifecycleState(AppLifecycleState.resumed) BEFORE starting auto-refresh ticker.

See detailed analysis in: #1158 (comment)


cc: @grdsdev

Resolved conflict in supabase_flutter.yml: adopted main's approach of
using continue-on-error for WASM tests instead of commenting them out,
which provides better visibility while allowing CI to proceed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 23, 2026 14:15
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors GoTrue auth token refresh synchronization into a dedicated TokenRefreshHandler to better coordinate concurrent refresh requests, improve disposal behavior, and expand unit test coverage (addresses refresh race-condition issues noted in #895/#930 and improves error handling for #928).

Changes:

  • Introduces TokenRefreshHandler (dedupe same-token refreshes, queue different-token refreshes) and wires it into GoTrueClient.
  • Adds AuthClientDisposedException plus unit tests for disposed-state behavior.
  • Applies broad formatting-only updates in storage_client and realtime_client (tests + some library files).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/gotrue/lib/src/token_refresh_handler.dart New refresh coordinator implementing dedupe/queue + disposal semantics
packages/gotrue/lib/src/gotrue_client.dart Replaces inline refresh synchronization with TokenRefreshHandler and adds dispose guards
packages/gotrue/lib/src/types/auth_exception.dart Adds AuthClientDisposedException
packages/gotrue/test/src/token_refresh_handler_test.dart New comprehensive unit tests for handler behavior
packages/gotrue/test/src/types/auth_exception_test.dart Adds tests for the new disposed exception
packages/storage_client/** Formatting-only changes (tests + minor refactors)
packages/realtime_client/** Formatting-only changes (tests + minor refactors)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


try {
final data = await _refreshCallback(refreshToken);

Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

If dispose() is called while _refreshCallback is in-flight, the first caller awaiting _startOperation can still receive a successful AuthResponse (because disposal only completes the internal completer). That contradicts the intended “pending operations complete with errors on dispose” semantics and can leak a usable response after the client is disposed. Consider checking _isDisposed immediately after awaiting _refreshCallback and, if disposed, completing (if needed) and throwing AuthClientDisposedException without invoking success/error callbacks.

Suggested change
// If the client was disposed while the refresh callback was in-flight,
// ensure pending callers observe a disposal error rather than a success.
if (_isDisposed) {
final disposedError = AuthClientDisposedException();
if (!operation.isCompleted) {
operation.completeError(disposedError);
}
throw disposedError;
}

Copilot uses AI. Check for mistakes.
Comment on lines +243 to +244
// Continue processing to clear remaining queued operations
_processNextQueued();
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

_processNextQueued drains the queue via recursion when _isDisposed is true. With a large _pendingOperations list this can cause deep recursion/stack overflow. Consider rewriting this branch to iteratively drain the queue (e.g., a while loop) and complete each operation with AuthClientDisposedException.

Suggested change
// Continue processing to clear remaining queued operations
_processNextQueued();
// Iteratively clear remaining queued operations to avoid deep recursion.
while (_pendingOperations.isNotEmpty) {
final queuedOperation = _pendingOperations.removeAt(0);
queuedOperation.completeError(
AuthClientDisposedException(operation: 'queued token refresh'),
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +126 to +134
// Check if we already have a queued operation for this token
final existingQueued = _pendingOperations
.cast<RefreshOperation?>()
.firstWhere((op) => op!.isForToken(refreshToken), orElse: () => null);

if (existingQueued != null) {
_log.finer('Joining existing queued operation for same token');
return existingQueued.future;
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

The queued-operation lookup uses cast<RefreshOperation?>() plus null assertions and orElse: () => null, which is hard to read and easy to misuse. Since _pendingOperations is already List<RefreshOperation>, consider a simpler lookup (e.g., firstWhere with try/catch or a helper like firstWhereOrNull) to avoid the unnecessary cast and ! usage.

Copilot uses AI. Check for mistakes.
Comment on lines +344 to +346
// The original exception is rethrown
expect(caughtError, isA<Exception>());
// But the error callback receives the wrapped AuthUnknownException
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Comment mismatch: this code throws the wrapped AuthUnknownException, not the original non-AuthException error. Consider updating the comment to reflect the actual behavior so the test remains a reliable reference.

Suggested change
// The original exception is rethrown
expect(caughtError, isA<Exception>());
// But the error callback receives the wrapped AuthUnknownException
// The non-AuthException error is wrapped and thrown as AuthUnknownException
expect(caughtError, isA<AuthUnknownException>());
// And the error callback also receives the wrapped AuthUnknownException

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +276
/// Logs the message. Override `this.logger` for specialized logging.
///
/// [level] must be [Level.FINEST] for senitive data
void log(
[String? kind, String? msg, dynamic data, Level level = Level.FINEST]) {
void log([
String? kind,
String? msg,
dynamic data,
Level level = Level.FINEST,
]) {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Typo in doc comment: "senitive" should be "sensitive".

Copilot uses AI. Check for mistakes.
Comment on lines +294 to +300
/// Registers a callback that will be executed when the channel encounteres an error.
void _onError(Function callback) {
onEvents(ChannelEvents.error.eventName(), ChannelFilter(),
(reason, [ref]) => callback(reason));
onEvents(
ChannelEvents.error.eventName(),
ChannelFilter(),
(reason, [ref]) => callback(reason),
);
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Typo in doc comment: "encounteres" should be "encounters".

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +41
file = File(
join(Directory.current.path, 'test', 'fixtures', 'upload', 'sadcat.jpg'),
);
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

This PR’s title/description focus on auth token refresh refactoring, but this file includes a sizable set of formatting-only changes in storage client tests. Consider splitting pure formatting changes into a separate PR (or limiting formatting to touched lines) to keep the auth-focused change easier to review and reduce merge noise.

Copilot uses AI. Check for mistakes.
grdsdev and others added 4 commits March 23, 2026 11:22
…ken refresh

- Guard _handleRefreshError against being called after dispose() closes
  streams; calling notifyAllSubscribers on a closed BehaviorSubject throws
  StateError because rxdart always forwards to the underlying controller
- Exclude AuthClientDisposedException from the sign-out path in
  _handleRefreshError; disposal is not an auth failure and should not
  broadcast a signedOut event to subscribers
- Remove unnecessary .cast<RefreshOperation?>() in refresh() by using
  firstOrNull from package:collection instead
- Fix misleading test comment and weak isA<Exception>() assertion for the
  non-AuthException wrapping test; it now asserts isA<AuthUnknownException>()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Formatting changes left unstaged by the pre-commit hook during the
main merge commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…torage_client

The pre-commit hook reformatted these packages with an older Dart version
during the main merge commit, introducing formatting that doesn't match
what CI's Dart 3.11.3 on Linux expects. These packages have no actual
code changes in this PR, so restore them to main's versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove formatting artifacts introduced by the pre-commit hook running
with a local Dart version incompatible with CI's Linux Dart formatter.
These packages have no actual code changes in this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth This issue or pull request is related to authentication

Projects

None yet

2 participants