Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/fresh/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 0.6.2

- feat: add `tokenWaitingRefresh` getter to `FreshMixin` — awaits any in-flight refresh before returning the current token ([#141](https://github.com/felangel/fresh/issues/141), [#142](https://github.com/felangel/fresh/pull/142))

# 0.6.1.

- fix: guard authenticationStatus emits on whether internal controller is closed
Expand Down
2 changes: 1 addition & 1 deletion packages/fresh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@ class MyHttpFresh extends MyHttpClient with FreshMixin<OAuth2Token> {
}
```

`refreshToken()` handles deduplication automatically - concurrent calls share a single in-flight refresh.
`refreshToken()` handles deduplication automatically - concurrent calls share a single in-flight refresh. Use `tokenWaitingRefresh` instead of `token` before sending requests to wait for any in-flight refresh and avoid dispatching with a stale token.
17 changes: 17 additions & 0 deletions packages/fresh/lib/src/fresh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ mixin FreshMixin<T> {
/// Calling this method more than once is allowed, but does nothing.
Future<void> close() => _controller.close();

/// Returns the current token, waiting for any in-flight refresh to
/// complete first. Unlike [refreshToken], this does **not** start a new
/// refresh — it only piggy-backs on one that is already running.
///
/// Use this before sending a request to avoid using a stale token while
/// another request is already refreshing it.
@protected
Future<T?> get tokenWaitingRefresh async {
final pending = _refreshFuture;
if (pending != null) {
try {
await pending;
} catch (_) {}
}
return token;
}

/// Performs the token refresh operation.
///
/// Implementers should provide only the raw token refresh mechanism.
Expand Down
48 changes: 48 additions & 0 deletions packages/fresh/test/fresh_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class FreshController<T> with FreshMixin<T> {

Future<T> Function(T? token)? refreshTokenFn;

// Expose @protected getter for testing.
Future<T?> get testTokenWaitingRefresh => tokenWaitingRefresh;

@override
Future<T> performTokenRefresh(T? token) {
final refreshAction = refreshTokenFn;
Expand Down Expand Up @@ -524,6 +527,51 @@ void main() {
);
});

group('tokenWaitingRefresh', () {
setUpAll(() {
registerFallbackValue(FakeOAuth2Token());
});

test('returns current token when no refresh is pending', () async {
final token = MockToken();
when(() => tokenStorage.read()).thenAnswer((_) async => token);
when(() => tokenStorage.write(any())).thenAnswer((_) async {});

final freshController = FreshController<OAuth2Token>(tokenStorage);
await freshController.setToken(token);

final result = await freshController.testTokenWaitingRefresh;
expect(result, token);
});

test('waits for in-flight refresh and returns updated token', () async {
final oldToken = MockToken();
final newToken = MockToken();
final refreshCompleter = Completer<OAuth2Token>();

when(() => tokenStorage.read()).thenAnswer((_) async => oldToken);
when(() => tokenStorage.write(any())).thenAnswer((_) async {});

final freshController = FreshController<OAuth2Token>(tokenStorage);
await freshController.setToken(oldToken);

freshController.refreshTokenFn = (_) => refreshCompleter.future;

// Start a refresh without awaiting to simulate an in-flight refresh.
// ignore: unused_local_variable
final pendingRefresh =
freshController.refreshToken(tokenUsedForRequest: oldToken);

// tokenWaitingRefresh should wait for the in-flight refresh
final resultFuture = freshController.testTokenWaitingRefresh;

refreshCompleter.complete(newToken);

final result = await resultFuture;
expect(result, newToken);
});
});

group('initial storage read', () {
setUpAll(() {
registerFallbackValue(FakeOAuth2Token());
Expand Down
4 changes: 4 additions & 0 deletions packages/fresh_dio/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 0.6.1

- fix: new requests during in-flight token refresh no longer sent with stale token ([#141](https://github.com/felangel/fresh/issues/141), [#142](https://github.com/felangel/fresh/pull/142))

# 0.6.0

- **BREAKING** feat: adjust the default `shouldRefreshBeforeRequest` to automatically refresh if the token expires within 30s
Expand Down
2 changes: 1 addition & 1 deletion packages/fresh_dio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ dio.interceptors.add(
1. **Before each request**: If the token has an `expiresAt` date in the past, it is refreshed proactively.
2. **Auth header**: The current token is attached to the request as an `Authorization` header.
3. **On 401 response**: The token is refreshed and the request is retried automatically.
4. **Concurrent requests**: If multiple requests trigger a refresh simultaneously, only one refresh call is made. The others wait for the result.
4. **Concurrent requests**: If multiple requests trigger a refresh simultaneously, only one refresh call is made. The others wait for the result. New requests arriving while a refresh is in-flight also wait and are sent with the updated token.

## Authentication Status

Expand Down
6 changes: 5 additions & 1 deletion packages/fresh_dio/lib/src/fresh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ Example:
return handler.next(options);
}

var currentToken = await token;
// Wait for any in-flight refresh (triggered by a 401 in onError/onResponse)
// before reading the token. QueuedInterceptor runs onRequest and onError
// on separate queues, so without this a new request could be sent with a
// stale token that the backend has already invalidated.
var currentToken = await tokenWaitingRefresh;

final shouldRefresh = _shouldRefreshBeforeRequest(
options,
Expand Down
200 changes: 200 additions & 0 deletions packages/fresh_dio/test/concurrent_refresh_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,186 @@ void main() {
},
);

test(
'WITHOUT fix: new request during in-flight refresh gets '
'unnecessary 401 due to separate QueuedInterceptor queues',
() async {
var refreshCallCount = 0;
final refreshCompleter = Completer<OAuth2Token>();
var total401Count = 0;

final mockAdapter = _MockAdapter(
(options) {
final auth = options.headers['authorization'] as String?;
if (auth == 'bearer new.token.jwt') {
return ResponseBody.fromString(
'{"success": true}',
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
total401Count++;
return ResponseBody.fromString(
'{"error": "Unauthorized"}',
401,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
},
);

final retryClient = Dio()..httpClientAdapter = mockAdapter;

// _UnfixedFresh overrides tokenWaitingRefresh to NOT wait,
// simulating the behavior without the fix.
final fresh = _UnfixedFresh(
tokenStorage: InMemoryTokenStorage<OAuth2Token>(),
refreshToken: (_, __) async {
refreshCallCount++;
return refreshCompleter.future;
},
tokenHeader: (token) => {
'authorization': '${token.tokenType} ${token.accessToken}',
},
httpClient: retryClient,
);

await fresh.setToken(
const OAuth2Token(
accessToken: 'old.token.jwt',
refreshToken: 'refreshToken',
),
);

final dio = Dio()..httpClientAdapter = mockAdapter;
dio.interceptors.add(fresh);

final future1 = dio.get<Object?>('http://example.com/1');
await pumpEventQueue();
expect(refreshCallCount, equals(1));

// Without the fix, onRequest doesn't wait for the in-flight refresh
// and sends the request with the old (invalidated) token.
final future2 = dio.get<Object?>('http://example.com/2');
await pumpEventQueue();

refreshCompleter.complete(
const OAuth2Token(
accessToken: 'new.token.jwt',
refreshToken: 'newRefreshToken',
),
);

final responses = await Future.wait([future1, future2]);

for (final response in responses) {
expect(response.statusCode, equals(200));
}

expect(refreshCallCount, equals(1));

// 2 backend 401s: request 1 (expected) + request 2 (unnecessary).
expect(
total401Count,
equals(2),
reason: 'Without fix, request 2 is sent with the old token '
'while refresh is in-flight, causing an extra 401',
);
},
);

test(
'WITH fix: new request during in-flight refresh waits for refresh '
'and avoids unnecessary 401',
() async {
var refreshCallCount = 0;
final refreshCompleter = Completer<OAuth2Token>();
var total401Count = 0;

final mockAdapter = _MockAdapter(
(options) {
final auth = options.headers['authorization'] as String?;
if (auth == 'bearer new.token.jwt') {
return ResponseBody.fromString(
'{"success": true}',
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
total401Count++;
return ResponseBody.fromString(
'{"error": "Unauthorized"}',
401,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
},
);

final retryClient = Dio()..httpClientAdapter = mockAdapter;

final fresh = Fresh.oAuth2(
tokenStorage: InMemoryTokenStorage<OAuth2Token>(),
refreshToken: (_, __) async {
refreshCallCount++;
return refreshCompleter.future;
},
httpClient: retryClient,
);

await fresh.setToken(
const OAuth2Token(
accessToken: 'old.token.jwt',
refreshToken: 'refreshToken',
),
);

final dio = Dio()..httpClientAdapter = mockAdapter;
dio.interceptors.add(fresh);

// First request → 401 → triggers refresh
final future1 = dio.get<Object?>('http://example.com/1');
await pumpEventQueue();
expect(refreshCallCount, equals(1));

// New request while refresh is in-flight.
// tokenWaitingRefresh in onRequest causes it to wait for the
// in-flight refresh, so it will use the new token immediately.
final future2 = dio.get<Object?>('http://example.com/2');
await pumpEventQueue();

refreshCompleter.complete(
const OAuth2Token(
accessToken: 'new.token.jwt',
refreshToken: 'newRefreshToken',
),
);

final responses = await Future.wait([future1, future2]);

for (final response in responses) {
expect(response.statusCode, equals(200));
}

expect(refreshCallCount, equals(1));

// Request 2 waited for the refresh and was sent with the new token,
// so only 1 backend 401 (from request 1).
expect(
total401Count,
equals(1),
reason: 'Request 2 should wait for refresh and use new token '
'instead of getting an unnecessary 401',
);
},
);

test(
'after successful refresh, a later 401 triggers a new refresh',
() async {
Expand Down Expand Up @@ -452,6 +632,26 @@ class _MockAdapter implements HttpClientAdapter {
}
}

/// Simulates Fresh WITHOUT the tokenWaitingRefresh fix.
/// [tokenWaitingRefresh] falls back to [token], so onRequest never waits
/// for an in-flight refresh triggered by onError.
class _UnfixedFresh extends Fresh<OAuth2Token> {
_UnfixedFresh({
required TokenStorage<OAuth2Token> tokenStorage,
required RefreshToken<OAuth2Token> refreshToken,
required TokenHeaderBuilder<OAuth2Token> tokenHeader,
Dio? httpClient,
}) : super(
tokenStorage: tokenStorage,
refreshToken: refreshToken,
tokenHeader: tokenHeader,
httpClient: httpClient,
);

@override
Future<OAuth2Token?> get tokenWaitingRefresh => token;
}

class _TrackingTokenStorage<T> implements TokenStorage<T> {
T? _token;
void Function()? onDelete;
Expand Down
4 changes: 4 additions & 0 deletions packages/fresh_graphql/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 0.8.1

- fix: new requests during in-flight token refresh no longer sent with stale token ([#141](https://github.com/felangel/fresh/issues/141), [#142](https://github.com/felangel/fresh/pull/142))

# 0.8.0

- **BREAKING** feat: adjust the default `shouldRefreshBeforeRequest` to automatically refresh if the token expires within 30s
Expand Down
2 changes: 1 addition & 1 deletion packages/fresh_graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ final freshLink = FreshLink<String>(
1. **Before each request**: If the token has an `expiresAt` date in the past, it is refreshed proactively.
2. **Auth header**: The current token is attached to the request via `HttpLinkHeaders`.
3. **On error response**: When `shouldRefresh` returns true, the token is refreshed and the request is retried.
4. **Concurrent requests**: If multiple GraphQL streams trigger a refresh simultaneously, only one refresh call is made. The others wait for the result.
4. **Concurrent requests**: If multiple GraphQL streams trigger a refresh simultaneously, only one refresh call is made. The others wait for the result. New requests arriving while a refresh is in-flight also wait and are sent with the updated token.

## Authentication Status

Expand Down
5 changes: 4 additions & 1 deletion packages/fresh_graphql/lib/src/fresh_link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ class FreshLink<T> extends Link with FreshMixin<T> {

@override
Stream<Response> request(Request request, [NextLink? forward]) async* {
var currentToken = await token;
// Wait for any in-flight refresh before reading the token. Without this,
// concurrent request() calls could use a stale token that the backend has
// already invalidated during a refresh triggered by another request.
var currentToken = await tokenWaitingRefresh;

final shouldRefresh = _shouldRefreshBeforeRequest.call(
request,
Expand Down
Loading
Loading