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(), + }); + }, ); }