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 /// the server again. This prevents thundering herd on page loads and
/// rapid retry-button clicks. /// rapid retry-button clicks.
class DedupInterceptor extends Interceptor { 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 @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// Only dedup GET requests (mutations must always go through) // Only dedup GET requests (mutations must always go through).
if (options.method.toUpperCase() != 'GET') { // 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); return handler.next(options);
} }
final key = '${options.method}:${options.uri}'; final key = '${options.method}:${options.uri}';
if (_pending.containsKey(key)) { final entry = _pending[key];
// Another identical request is in flight wait for it if (entry != null) {
_pending[key]!.future.then( // Another identical request is in flight piggyback on it.
entry.listeners++;
entry.completer.future.then(
(response) => handler.resolve( (response) => handler.resolve(
Response( Response(
requestOptions: options, requestOptions: options,
@ -29,9 +35,15 @@ class DedupInterceptor extends Interceptor {
headers: response.headers, headers: response.headers,
), ),
), ),
onError: (e) { onError: (Object e) {
if (e is DioException) { 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 { } else {
handler.reject(DioException( handler.reject(DioException(
requestOptions: options, requestOptions: options,
@ -43,24 +55,38 @@ class DedupInterceptor extends Interceptor {
return; return;
} }
// First request for this key register and proceed // First request for this key register and proceed.
_pending[key] = Completer<Response>(); _pending[key] = _PendingEntry(Completer<Response>());
handler.next(options); handler.next(options);
} }
@override @override
void onResponse(Response response, ResponseInterceptorHandler handler) { void onResponse(Response response, ResponseInterceptorHandler handler) {
final key = '${response.requestOptions.method}:${response.requestOptions.uri}'; _complete(response.requestOptions, response: response);
_pending[key]?.complete(response);
_pending.remove(key);
handler.next(response); handler.next(response);
} }
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
final key = '${err.requestOptions.method}:${err.requestOptions.uri}'; _complete(err.requestOptions, error: err);
_pending[key]?.completeError(err);
_pending.remove(key);
handler.next(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:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import 'dedup_interceptor.dart'; import 'dedup_interceptor.dart';
@ -14,9 +15,9 @@ final dioClientProvider = Provider<Dio>((ref) {
final config = ref.watch(appConfigProvider); final config = ref.watch(appConfigProvider);
final dio = Dio(BaseOptions( final dio = Dio(BaseOptions(
baseUrl: config.apiBaseUrl, baseUrl: config.apiBaseUrl,
connectTimeout: const Duration(seconds: 8), connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 15), sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 20), receiveTimeout: const Duration(seconds: 30),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@ -26,34 +27,45 @@ final dioClientProvider = Provider<Dio>((ref) {
dio.interceptors.add(DedupInterceptor()); dio.interceptors.add(DedupInterceptor());
// 2. Token interceptor (with refresh lock to prevent concurrent refreshes) // 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( dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final storage = ref.read(secureStorageProvider); try {
final token = await storage.read(key: 'access_token'); final storage = ref.read(secureStorageProvider);
if (token != null) { final token = await storage.read(key: 'access_token');
options.headers['Authorization'] = 'Bearer $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 { onError: (error, handler) async {
if (error.response?.statusCode == 401) { try {
// Use lock: only 1 refresh at a time, others wait for the result if (error.response?.statusCode == 401) {
final refreshed = await _tokenRefreshLock.run(() { final refreshed = await _tokenRefreshLock.run(() {
final authNotifier = ref.read(authStateProvider.notifier); final authNotifier = ref.read(authStateProvider.notifier);
return authNotifier.refreshToken(); return authNotifier.refreshToken();
}); });
if (refreshed) { if (refreshed) {
final storage = ref.read(secureStorageProvider); final storage = ref.read(secureStorageProvider);
final newToken = await storage.read(key: 'access_token'); final newToken = await storage.read(key: 'access_token');
if (newToken != null) { if (newToken != null) {
error.requestOptions.headers['Authorization'] = error.requestOptions.headers['Authorization'] =
'Bearer $newToken'; 'Bearer $newToken';
final retryResponse = await dio.fetch(error.requestOptions); final retryResponse = await dio.fetch(error.requestOptions);
return handler.resolve(retryResponse); 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 'dart:math';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// Dio interceptor that automatically retries failed requests with /// Dio interceptor that automatically retries failed requests with
/// exponential backoff for transient/recoverable errors. /// 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 { class RetryInterceptor extends Interceptor {
final Dio dio; final Dio dio;
final int maxRetries; final int maxRetries;
@ -15,7 +20,7 @@ class RetryInterceptor extends Interceptor {
}); });
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { void onError(DioException err, ErrorInterceptorHandler handler) {
if (!_shouldRetry(err)) { if (!_shouldRetry(err)) {
return handler.next(err); return handler.next(err);
} }
@ -25,31 +30,44 @@ class RetryInterceptor extends Interceptor {
return handler.next(err); return handler.next(err);
} }
// Exponential backoff with jitter // Fire-and-forget with full error containment never leaks futures.
final delay = baseDelay * pow(2, retryCount).toInt(); _retry(err, handler, retryCount);
final jitter = Duration(milliseconds: Random().nextInt(500)); }
await Future.delayed(delay + jitter);
// Increment retry count
err.requestOptions.extra['_retryCount'] = retryCount + 1;
Future<void> _retry(
DioException err,
ErrorInterceptorHandler handler,
int retryCount,
) async {
try { 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); final response = await dio.fetch(err.requestOptions);
return handler.resolve(response); handler.resolve(response);
} on DioException catch (e) { } 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) { 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 || if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout || err.type == DioExceptionType.connectionTimeout) {
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
return true; 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") // Unknown errors (e.g. "Connection reset by peer")
if (err.type == DioExceptionType.unknown && err.error != null) { if (err.type == DioExceptionType.unknown && err.error != null) {
return true; return true;

View File

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