fix: API interceptor 增加 token 刷新单飞机制防止并发刷新
This commit is contained in:
@@ -4,11 +4,18 @@ import '../storage/token_storage.dart';
|
|||||||
class ApiInterceptor extends Interceptor {
|
class ApiInterceptor extends Interceptor {
|
||||||
final TokenStorage tokenStorage;
|
final TokenStorage tokenStorage;
|
||||||
final Dio dio;
|
final Dio dio;
|
||||||
|
final Duration refreshFailureCooldown;
|
||||||
Future<bool> Function()? onTokenRefresh;
|
Future<bool> Function()? onTokenRefresh;
|
||||||
|
Future<bool>? _refreshFuture;
|
||||||
|
DateTime? _refreshBlockedUntil;
|
||||||
|
|
||||||
|
static const _retriedRequestKey = '_auth_retry_once';
|
||||||
|
static const _refreshPathSuffix = '/api/v1/auth/sessions/refresh';
|
||||||
|
|
||||||
ApiInterceptor({
|
ApiInterceptor({
|
||||||
required this.tokenStorage,
|
required this.tokenStorage,
|
||||||
required this.dio,
|
required this.dio,
|
||||||
|
this.refreshFailureCooldown = const Duration(seconds: 5),
|
||||||
this.onTokenRefresh,
|
this.onTokenRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,22 +33,69 @@ class ApiInterceptor extends Interceptor {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
if (err.response?.statusCode == 401 && onTokenRefresh != null) {
|
final requestOptions = err.requestOptions;
|
||||||
final refreshed = await onTokenRefresh!();
|
if (err.response?.statusCode == 401 &&
|
||||||
|
onTokenRefresh != null &&
|
||||||
|
!_shouldSkipRefresh(requestOptions)) {
|
||||||
|
final refreshed = await _refreshTokenSingleflight();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
final token = await tokenStorage.getAccessToken();
|
final token = await tokenStorage.getAccessToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
err.requestOptions.headers['Authorization'] = 'Bearer $token';
|
final retryHeaders = Map<String, dynamic>.from(requestOptions.headers)
|
||||||
|
..['Authorization'] = 'Bearer $token';
|
||||||
|
final retryExtra = Map<String, dynamic>.from(requestOptions.extra)
|
||||||
|
..[_retriedRequestKey] = true;
|
||||||
|
final retryOptions = requestOptions.copyWith(
|
||||||
|
headers: retryHeaders,
|
||||||
|
extra: retryExtra,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final response = await dio.fetch(err.requestOptions);
|
final response = await dio.fetch(retryOptions);
|
||||||
handler.resolve(response);
|
handler.resolve(response);
|
||||||
return;
|
return;
|
||||||
} on DioException {
|
} on DioException {
|
||||||
// Retry failed, proceed with original error
|
// Retry failed, proceed with original error.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldSkipRefresh(RequestOptions options) {
|
||||||
|
final blockedUntil = _refreshBlockedUntil;
|
||||||
|
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _normalizePath(options.path) == _refreshPathSuffix ||
|
||||||
|
options.extra[_retriedRequestKey] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizePath(String rawPath) {
|
||||||
|
final parsed = Uri.tryParse(rawPath);
|
||||||
|
if (parsed == null) {
|
||||||
|
return rawPath.replaceFirst(RegExp(r'/+$'), '');
|
||||||
|
}
|
||||||
|
final normalized = parsed.path.replaceFirst(RegExp(r'/+$'), '');
|
||||||
|
return normalized.isEmpty ? '/' : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _refreshTokenSingleflight() {
|
||||||
|
final inflight = _refreshFuture;
|
||||||
|
if (inflight != null) {
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
final future = onTokenRefresh!().catchError((_) => false).whenComplete(() {
|
||||||
|
_refreshFuture = null;
|
||||||
|
});
|
||||||
|
_refreshFuture = future;
|
||||||
|
return future.then((refreshed) {
|
||||||
|
if (refreshed) {
|
||||||
|
_refreshBlockedUntil = null;
|
||||||
|
} else {
|
||||||
|
_refreshBlockedUntil = DateTime.now().add(refreshFailureCooldown);
|
||||||
|
}
|
||||||
|
return refreshed;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:social_app/core/api/api_interceptor.dart';
|
||||||
|
import 'package:social_app/core/storage/token_storage.dart';
|
||||||
|
|
||||||
|
class MockTokenStorage extends Mock implements TokenStorage {}
|
||||||
|
|
||||||
|
class MockErrorInterceptorHandler extends Mock
|
||||||
|
implements ErrorInterceptorHandler {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockTokenStorage tokenStorage;
|
||||||
|
late MockErrorInterceptorHandler handler;
|
||||||
|
late ApiInterceptor interceptor;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(
|
||||||
|
DioException(requestOptions: RequestOptions(path: '/fallback')),
|
||||||
|
);
|
||||||
|
registerFallbackValue(
|
||||||
|
Response<dynamic>(requestOptions: RequestOptions(path: '/fallback')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
tokenStorage = MockTokenStorage();
|
||||||
|
handler = MockErrorInterceptorHandler();
|
||||||
|
interceptor = ApiInterceptor(
|
||||||
|
tokenStorage: tokenStorage,
|
||||||
|
dio: Dio(),
|
||||||
|
refreshFailureCooldown: const Duration(milliseconds: 80),
|
||||||
|
);
|
||||||
|
when(() => handler.next(any())).thenReturn(null);
|
||||||
|
when(() => handler.resolve(any())).thenReturn(null);
|
||||||
|
when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
DioException _unauthorized(String path) {
|
||||||
|
final requestOptions = RequestOptions(path: path);
|
||||||
|
return DioException(
|
||||||
|
requestOptions: requestOptions,
|
||||||
|
response: Response<dynamic>(
|
||||||
|
requestOptions: requestOptions,
|
||||||
|
statusCode: 401,
|
||||||
|
),
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('401并发请求仅触发一次refresh', () async {
|
||||||
|
var refreshCalls = 0;
|
||||||
|
interceptor.onTokenRefresh = () async {
|
||||||
|
refreshCalls += 1;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
|
||||||
|
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(refreshCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh接口401不应再次触发refresh', () async {
|
||||||
|
var refreshCalls = 0;
|
||||||
|
interceptor.onTokenRefresh = () async {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
interceptor.onError(
|
||||||
|
_unauthorized('/api/v1/auth/sessions/refresh'),
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
expect(refreshCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh接口带query时401也不应再次触发refresh', () async {
|
||||||
|
var refreshCalls = 0;
|
||||||
|
interceptor.onTokenRefresh = () async {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
interceptor.onError(
|
||||||
|
_unauthorized('/api/v1/auth/sessions/refresh?source=boot'),
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
expect(refreshCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh失败冷却期内不应重复触发refresh', () async {
|
||||||
|
var refreshCalls = 0;
|
||||||
|
interceptor.onTokenRefresh = () async {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
interceptor.onError(_unauthorized('/api/v1/agent/history'), handler);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
|
expect(refreshCalls, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user