From 666b17390637469e16520faac0f09aba982e49d0 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 04:37:39 -0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A0=B9=E6=B2=BB=20Unhandled=20Excepti?= =?UTF-8?q?on=20=E2=80=94=20async=20void=20=E6=8B=A6=E6=88=AA=E5=99=A8=20+?= =?UTF-8?q?=20=E5=85=A8=E5=B1=80=E9=94=99=E8=AF=AF=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根本原因:Dio interceptor 的 onError/onRequest 签名是 void, 标 async 后变成 Future 但没人 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 --- .../lib/core/network/dedup_interceptor.dart | 58 ++++++++++----- it0_app/lib/core/network/dio_client.dart | 58 +++++++++------ .../lib/core/network/retry_interceptor.dart | 46 ++++++++---- it0_app/lib/main.dart | 71 ++++++++++++++----- 4 files changed, 164 insertions(+), 69 deletions(-) diff --git a/it0_app/lib/core/network/dedup_interceptor.dart b/it0_app/lib/core/network/dedup_interceptor.dart index fc0df6f..af9cdb2 100644 --- a/it0_app/lib/core/network/dedup_interceptor.dart +++ b/it0_app/lib/core/network/dedup_interceptor.dart @@ -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 = >{}; + /// Stores both the result completer and a listener count. + /// When count drops to 0 on error, we avoid completing an orphan completer. + final _pending = {}; @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(); + // First request for this key — register and proceed. + _pending[key] = _PendingEntry(Completer()); 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 completer; + int listeners; + _PendingEntry(this.completer, {this.listeners = 0}); } diff --git a/it0_app/lib/core/network/dio_client.dart b/it0_app/lib/core/network/dio_client.dart index c0f12d7..1ba083c 100644 --- a/it0_app/lib/core/network/dio_client.dart +++ b/it0_app/lib/core/network/dio_client.dart @@ -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((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((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); }, )); diff --git a/it0_app/lib/core/network/retry_interceptor.dart b/it0_app/lib/core/network/retry_interceptor.dart index 7ae8304..ef6a88a 100644 --- a/it0_app/lib/core/network/retry_interceptor.dart +++ b/it0_app/lib/core/network/retry_interceptor.dart @@ -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 _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; diff --git a/it0_app/lib/main.dart b/it0_app/lib/main.dart index f4f2824..d3d5198 100644 --- a/it0_app/lib/main.dart +++ b/it0_app/lib/main.dart @@ -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(), + }); + }, ); }