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
|
||||
/// 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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,18 +27,25 @@ 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 {
|
||||
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);
|
||||
}
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
try {
|
||||
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();
|
||||
|
|
@ -54,6 +62,10 @@ final dioClientProvider = Provider<Dio>((ref) {
|
|||
}
|
||||
}
|
||||
handler.next(error);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('[TokenInterceptor] onError error: $e');
|
||||
handler.next(error);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Increment retry count
|
||||
err.requestOptions.extra['_retryCount'] = retryCount + 1;
|
||||
|
||||
try {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,8 +8,33 @@ import 'app.dart';
|
|||
import 'core/services/error_logger.dart';
|
||||
import 'features/notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
void main() async {
|
||||
void main() {
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 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',
|
||||
});
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
||||
|
|
@ -20,9 +47,21 @@ void main() async {
|
|||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localNotificationsPluginProvider.overrideWithValue(localNotifications),
|
||||
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