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:
parent
68004409a3
commit
666b173906
|
|
@ -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});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue