diff --git a/it0_app/lib/core/network/dedup_interceptor.dart b/it0_app/lib/core/network/dedup_interceptor.dart new file mode 100644 index 0000000..fc0df6f --- /dev/null +++ b/it0_app/lib/core/network/dedup_interceptor.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; + +/// Deduplicates identical in-flight GET requests. +/// If the same GET URL is requested while a previous request is still pending, +/// the second request reuses the result of the first instead of hitting +/// the server again. This prevents thundering herd on page loads and +/// rapid retry-button clicks. +class DedupInterceptor extends Interceptor { + final _pending = >{}; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // Only dedup GET requests (mutations must always go through) + if (options.method.toUpperCase() != 'GET') { + 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( + (response) => handler.resolve( + Response( + requestOptions: options, + data: response.data, + statusCode: response.statusCode, + headers: response.headers, + ), + ), + onError: (e) { + if (e is DioException) { + handler.reject(e); + } else { + handler.reject(DioException( + requestOptions: options, + error: e, + )); + } + }, + ); + return; + } + + // First request for this key — register and proceed + _pending[key] = 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); + 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); + handler.next(err); + } +} diff --git a/it0_app/lib/core/network/dio_client.dart b/it0_app/lib/core/network/dio_client.dart index 68f8d35..c0f12d7 100644 --- a/it0_app/lib/core/network/dio_client.dart +++ b/it0_app/lib/core/network/dio_client.dart @@ -1,10 +1,15 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../config/app_config.dart'; +import 'dedup_interceptor.dart'; +import 'error_log_interceptor.dart'; import 'retry_interceptor.dart'; +import 'token_refresh_lock.dart'; import '../../features/auth/data/providers/auth_provider.dart'; import '../../features/auth/data/providers/tenant_provider.dart'; +final _tokenRefreshLock = TokenRefreshLock(); + final dioClientProvider = Provider((ref) { final config = ref.watch(appConfigProvider); final dio = Dio(BaseOptions( @@ -17,7 +22,10 @@ final dioClientProvider = Provider((ref) { }, )); - // Token interceptor + // 1. Request deduplication (coalesce identical GET requests in flight) + dio.interceptors.add(DedupInterceptor()); + + // 2. Token interceptor (with refresh lock to prevent concurrent refreshes) dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final storage = ref.read(secureStorageProvider); @@ -29,8 +37,11 @@ final dioClientProvider = Provider((ref) { }, onError: (error, handler) async { if (error.response?.statusCode == 401) { - final authNotifier = ref.read(authStateProvider.notifier); - final refreshed = await authNotifier.refreshToken(); + // 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'); @@ -46,7 +57,7 @@ final dioClientProvider = Provider((ref) { }, )); - // Tenant interceptor + // 3. Tenant interceptor dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { final tenantId = ref.read(currentTenantIdProvider); @@ -57,14 +68,11 @@ final dioClientProvider = Provider((ref) { }, )); - // Retry interceptor (exponential backoff for transient errors) + // 4. Retry interceptor (exponential backoff for transient errors) dio.interceptors.add(RetryInterceptor(dio: dio)); - // Logging interceptor - dio.interceptors.add(LogInterceptor( - requestBody: true, - responseBody: true, - )); + // 5. Error logging interceptor (structured, persistent) + dio.interceptors.add(ErrorLogInterceptor()); return dio; }); diff --git a/it0_app/lib/core/network/error_log_interceptor.dart b/it0_app/lib/core/network/error_log_interceptor.dart new file mode 100644 index 0000000..ed28efe --- /dev/null +++ b/it0_app/lib/core/network/error_log_interceptor.dart @@ -0,0 +1,30 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../services/error_logger.dart'; + +/// Interceptor that logs all failed requests to [ErrorLogger] for +/// structured diagnostics and future Sentry/Crashlytics integration. +class ErrorLogInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final entry = { + 'timestamp': DateTime.now().toIso8601String(), + 'method': err.requestOptions.method, + 'url': err.requestOptions.uri.toString(), + 'statusCode': err.response?.statusCode, + 'errorType': err.type.name, + 'message': err.message ?? err.error?.toString() ?? 'unknown', + 'retryCount': err.requestOptions.extra['_retryCount'] ?? 0, + }; + + ErrorLogger.instance.log(entry); + + if (kDebugMode) { + debugPrint('[API Error] ${entry['method']} ${entry['url']} ' + '→ ${entry['statusCode'] ?? entry['errorType']} ' + '(retries: ${entry['retryCount']})'); + } + + handler.next(err); + } +} diff --git a/it0_app/lib/core/network/token_refresh_lock.dart b/it0_app/lib/core/network/token_refresh_lock.dart new file mode 100644 index 0000000..3201a91 --- /dev/null +++ b/it0_app/lib/core/network/token_refresh_lock.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +/// Ensures only one token refresh runs at a time. +/// When multiple requests hit 401 simultaneously, only the first triggers +/// a refresh; the rest wait for the same result. +class TokenRefreshLock { + Completer? _refreshCompleter; + + /// Returns true if refresh succeeded. If a refresh is already in progress, + /// waits for that one instead of starting a new one. + Future run(Future Function() refreshFn) async { + // If a refresh is already running, wait for it + if (_refreshCompleter != null) { + return _refreshCompleter!.future; + } + + _refreshCompleter = Completer(); + try { + final result = await refreshFn(); + _refreshCompleter!.complete(result); + return result; + } catch (e) { + _refreshCompleter!.complete(false); + return false; + } finally { + _refreshCompleter = null; + } + } +} diff --git a/it0_app/lib/core/services/error_logger.dart b/it0_app/lib/core/services/error_logger.dart new file mode 100644 index 0000000..5275929 --- /dev/null +++ b/it0_app/lib/core/services/error_logger.dart @@ -0,0 +1,92 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Persistent error logger that stores recent API errors locally. +/// Keeps the last [maxEntries] errors for diagnostics. +/// Can be extended to flush to Sentry/Crashlytics/backend. +class ErrorLogger { + static final ErrorLogger instance = ErrorLogger._(); + ErrorLogger._(); + + static const String _storageKey = 'error_log'; + static const int maxEntries = 50; + + final _buffer = Queue>(); + bool _initialized = false; + + /// Loads persisted errors from SharedPreferences. + Future init() async { + if (_initialized) return; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_storageKey); + if (raw != null) { + final list = jsonDecode(raw) as List; + for (final item in list) { + _buffer.add(Map.from(item as Map)); + } + } + } catch (_) { + // Silent — don't let logging failures break the app + } + _initialized = true; + } + + /// Logs a structured error entry. + void log(Map entry) { + _buffer.addLast(entry); + while (_buffer.length > maxEntries) { + _buffer.removeFirst(); + } + _persist(); + } + + /// Returns all logged errors (newest last). + List> get entries => _buffer.toList(); + + /// Clears all logged errors. + Future clear() async { + _buffer.clear(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_storageKey); + } catch (_) {} + } + + /// Returns a summary: total errors, most frequent endpoint, last error time. + Map get summary { + if (_buffer.isEmpty) { + return {'totalErrors': 0}; + } + + // Count errors by URL + final urlCounts = {}; + for (final entry in _buffer) { + final url = entry['url'] as String? ?? 'unknown'; + // Strip query params for grouping + final path = Uri.tryParse(url)?.path ?? url; + urlCounts[path] = (urlCounts[path] ?? 0) + 1; + } + + final topUrl = urlCounts.entries.reduce( + (a, b) => a.value >= b.value ? a : b, + ); + + return { + 'totalErrors': _buffer.length, + 'mostFrequentEndpoint': topUrl.key, + 'mostFrequentCount': topUrl.value, + 'lastErrorAt': _buffer.last['timestamp'], + }; + } + + Future _persist() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, jsonEncode(_buffer.toList())); + } catch (_) { + // Silent + } + } +} diff --git a/it0_app/lib/main.dart b/it0_app/lib/main.dart index 77d4aae..f4f2824 100644 --- a/it0_app/lib/main.dart +++ b/it0_app/lib/main.dart @@ -3,11 +3,13 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; 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(); // Initialize local notifications final localNotifications = FlutterLocalNotificationsPlugin();