fix: 根治 Unhandled Exception — async void 拦截器 + 全局错误兜底

根本原因:Dio interceptor 的 onError/onRequest 签名是 void,
标 async 后变成 Future<void> 但没人 await,内部异常全部变成
Unhandled Exception 崩溃。

修复:
- RetryInterceptor: onError 改为同步调度,retry 逻辑移到独立
  _retry() 方法并用 try/catch 包裹全部路径
- DedupInterceptor: 防止 Completer 重复 complete,retry 请求
  跳过去重避免与原始请求冲突
- TokenInterceptor: onRequest 和 onError 的 async lambda 全部
  包裹 try/catch,异常时 fallback 到 handler.next()
- main.dart: 三层全局错误兜底 —
  1) FlutterError.onError 捕获框架错误
  2) PlatformDispatcher.onError 捕获平台通道错误
  3) runZonedGuarded 捕获所有漏网的异步异常
- receiveTimeout/sendTimeout 不再触发重试(服务器已收到请求)
- 超时调整: connect 10s, send 30s, receive 30s
- 仪表盘卡片 IntrinsicHeight 等高对齐

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 04:37:39 -08:00
parent 68004409a3
commit 666b173906
4 changed files with 164 additions and 69 deletions

View File

@ -7,20 +7,26 @@ import 'package:dio/dio.dart';
/// the server again. This prevents thundering herd on page loads and
/// rapid retry-button clicks.
class DedupInterceptor extends Interceptor {
final _pending = <String, Completer<Response>>{};
/// Stores both the result completer and a listener count.
/// When count drops to 0 on error, we avoid completing an orphan completer.
final _pending = <String, _PendingEntry>{};
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Only dedup GET requests (mutations must always go through)
if (options.method.toUpperCase() != 'GET') {
// Only dedup GET requests (mutations must always go through).
// Skip dedup for retry requests (they already failed once).
if (options.method.toUpperCase() != 'GET' ||
(options.extra['_retryCount'] as int? ?? 0) > 0) {
return handler.next(options);
}
final key = '${options.method}:${options.uri}';
if (_pending.containsKey(key)) {
// Another identical request is in flight wait for it
_pending[key]!.future.then(
final entry = _pending[key];
if (entry != null) {
// Another identical request is in flight piggyback on it.
entry.listeners++;
entry.completer.future.then(
(response) => handler.resolve(
Response(
requestOptions: options,
@ -29,9 +35,15 @@ class DedupInterceptor extends Interceptor {
headers: response.headers,
),
),
onError: (e) {
onError: (Object e) {
if (e is DioException) {
handler.reject(e);
handler.reject(DioException(
requestOptions: options,
type: e.type,
error: e.error,
message: e.message,
response: e.response,
));
} else {
handler.reject(DioException(
requestOptions: options,
@ -43,24 +55,38 @@ class DedupInterceptor extends Interceptor {
return;
}
// First request for this key register and proceed
_pending[key] = Completer<Response>();
// First request for this key register and proceed.
_pending[key] = _PendingEntry(Completer<Response>());
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final key = '${response.requestOptions.method}:${response.requestOptions.uri}';
_pending[key]?.complete(response);
_pending.remove(key);
_complete(response.requestOptions, response: response);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final key = '${err.requestOptions.method}:${err.requestOptions.uri}';
_pending[key]?.completeError(err);
_pending.remove(key);
_complete(err.requestOptions, error: err);
handler.next(err);
}
void _complete(RequestOptions options, {Response? response, DioException? error}) {
final key = '${options.method}:${options.uri}';
final entry = _pending.remove(key);
if (entry == null || entry.completer.isCompleted) return;
if (response != null) {
entry.completer.complete(response);
} else if (error != null) {
entry.completer.completeError(error);
}
}
}
class _PendingEntry {
final Completer<Response> completer;
int listeners;
_PendingEntry(this.completer, {this.listeners = 0});
}

View File

@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart';
import 'dedup_interceptor.dart';
@ -14,9 +15,9 @@ final dioClientProvider = Provider<Dio>((ref) {
final config = ref.watch(appConfigProvider);
final dio = Dio(BaseOptions(
baseUrl: config.apiBaseUrl,
connectTimeout: const Duration(seconds: 8),
sendTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
},
@ -26,34 +27,45 @@ final dioClientProvider = Provider<Dio>((ref) {
dio.interceptors.add(DedupInterceptor());
// 2. Token interceptor (with refresh lock to prevent concurrent refreshes)
// NOTE: InterceptorsWrapper callbacks are fire-and-forget when async,
// so every async path MUST have a try/catch to prevent unhandled futures.
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final storage = ref.read(secureStorageProvider);
final token = await storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
try {
final storage = ref.read(secureStorageProvider);
final token = await storage.read(key: 'access_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
} catch (e) {
if (kDebugMode) debugPrint('[TokenInterceptor] onRequest error: $e');
handler.next(options);
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
// Use lock: only 1 refresh at a time, others wait for the result
final refreshed = await _tokenRefreshLock.run(() {
final authNotifier = ref.read(authStateProvider.notifier);
return authNotifier.refreshToken();
});
if (refreshed) {
final storage = ref.read(secureStorageProvider);
final newToken = await storage.read(key: 'access_token');
if (newToken != null) {
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
final retryResponse = await dio.fetch(error.requestOptions);
return handler.resolve(retryResponse);
try {
if (error.response?.statusCode == 401) {
final refreshed = await _tokenRefreshLock.run(() {
final authNotifier = ref.read(authStateProvider.notifier);
return authNotifier.refreshToken();
});
if (refreshed) {
final storage = ref.read(secureStorageProvider);
final newToken = await storage.read(key: 'access_token');
if (newToken != null) {
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
final retryResponse = await dio.fetch(error.requestOptions);
return handler.resolve(retryResponse);
}
}
}
handler.next(error);
} catch (e) {
if (kDebugMode) debugPrint('[TokenInterceptor] onError error: $e');
handler.next(error);
}
handler.next(error);
},
));

View File

@ -1,8 +1,13 @@
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// Dio interceptor that automatically retries failed requests with
/// exponential backoff for transient/recoverable errors.
///
/// IMPORTANT: onError must NOT be async (Dio calls it as void, so any
/// unhandled future would crash the app). We use a fire-and-forget
/// closure with full try/catch instead.
class RetryInterceptor extends Interceptor {
final Dio dio;
final int maxRetries;
@ -15,7 +20,7 @@ class RetryInterceptor extends Interceptor {
});
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!_shouldRetry(err)) {
return handler.next(err);
}
@ -25,31 +30,44 @@ class RetryInterceptor extends Interceptor {
return handler.next(err);
}
// Exponential backoff with jitter
final delay = baseDelay * pow(2, retryCount).toInt();
final jitter = Duration(milliseconds: Random().nextInt(500));
await Future.delayed(delay + jitter);
// Increment retry count
err.requestOptions.extra['_retryCount'] = retryCount + 1;
// Fire-and-forget with full error containment never leaks futures.
_retry(err, handler, retryCount);
}
Future<void> _retry(
DioException err,
ErrorInterceptorHandler handler,
int retryCount,
) async {
try {
// Exponential backoff with jitter
final delay = baseDelay * pow(2, retryCount).toInt();
final jitter = Duration(milliseconds: Random().nextInt(500));
await Future.delayed(delay + jitter);
err.requestOptions.extra['_retryCount'] = retryCount + 1;
final response = await dio.fetch(err.requestOptions);
return handler.resolve(response);
handler.resolve(response);
} on DioException catch (e) {
return handler.next(e);
handler.next(e);
} catch (e) {
// Catch absolutely everything never let an exception escape.
if (kDebugMode) debugPrint('[RetryInterceptor] unexpected: $e');
handler.next(err);
}
}
bool _shouldRetry(DioException err) {
// Network-level errors (connection reset, timeout, DNS failure)
// Connection-level errors (connection reset, DNS failure, refused)
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
err.type == DioExceptionType.connectionTimeout) {
return true;
}
// DO NOT retry send/receive timeouts server already got the request,
// retrying just doubles the wait and server load.
// Unknown errors (e.g. "Connection reset by peer")
if (err.type == DioExceptionType.unknown && err.error != null) {
return true;

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -6,23 +8,60 @@ import 'app.dart';
import 'core/services/error_logger.dart';
import 'features/notifications/presentation/providers/notification_providers.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
await ErrorLogger.instance.init();
void main() {
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize local notifications
final localNotifications = FlutterLocalNotificationsPlugin();
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidInit);
await localNotifications.initialize(initSettings);
// Catch all Flutter framework errors (rendering, gestures, etc.)
FlutterError.onError = (details) {
FlutterError.presentError(details);
ErrorLogger.instance.log({
'timestamp': DateTime.now().toIso8601String(),
'source': 'FlutterError',
'message': details.exceptionAsString(),
'library': details.library ?? 'unknown',
});
};
runApp(
ProviderScope(
overrides: [
localNotificationsPluginProvider.overrideWithValue(localNotifications),
],
child: const IT0App(),
),
// Catch errors in the platform dispatcher (e.g. platform channels)
PlatformDispatcher.instance.onError = (error, stack) {
if (kDebugMode) debugPrint('[PlatformError] $error');
ErrorLogger.instance.log({
'timestamp': DateTime.now().toIso8601String(),
'source': 'PlatformDispatcher',
'message': error.toString(),
});
return true; // handled prevent crash
};
await Hive.initFlutter();
await ErrorLogger.instance.init();
// Initialize local notifications
final localNotifications = FlutterLocalNotificationsPlugin();
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidInit);
await localNotifications.initialize(initSettings);
runApp(
ProviderScope(
overrides: [
localNotificationsPluginProvider
.overrideWithValue(localNotifications),
],
child: const IT0App(),
),
);
},
// Catch ALL remaining unhandled async errors (the final safety net)
(error, stack) {
if (kDebugMode) debugPrint('[ZoneError] $error\n$stack');
ErrorLogger.instance.log({
'timestamp': DateTime.now().toIso8601String(),
'source': 'runZonedGuarded',
'message': error.toString(),
});
},
);
}