From 7535d908188d4271f61017703915ea3184993822 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 22:10:46 -0600 Subject: [PATCH 1/6] Add centralized retry/fallback policy with hard-coded endpoints Extract duplicated retry logic into shared executeWithFallback() with ErrorDisposition-based classification (abort/fallback/retry). Services use hard-coded primary+fallback endpoint pairs: - OverpassService: overpass.deflock.org + overpass-api.de - RoutingService: dontgetflocked.com + alprwatch.org Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 2 +- lib/services/overpass_service.dart | 186 +++++++------ lib/services/routing_service.dart | 96 +++++-- lib/services/service_policy.dart | 85 ++++++ lib/state/navigation_state.dart | 6 +- test/services/overpass_service_test.dart | 189 ++++++++++++- test/services/routing_service_test.dart | 329 +++++++++++++++++++++-- test/services/service_policy_test.dart | 172 ++++++++++++ 8 files changed, 912 insertions(+), 153 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 44294c69..1a47bdd5 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -65,7 +65,7 @@ const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up an const double kChangesetCloseBackoffMultiplier = 2.0; // Navigation routing configuration -const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests +const Duration kNavigationRoutingTimeout = Duration(seconds: 30); // HTTP timeout for routing requests (legacy — prefer ResiliencePolicy.httpTimeout) // Overpass API configuration const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 0d0f7ca5..6f33c5bb 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -8,97 +8,113 @@ import '../models/node_profile.dart'; import '../models/osm_node.dart'; import '../dev_config.dart'; import 'http_client.dart'; +import 'service_policy.dart'; -/// Simple Overpass API client with proper HTTP retry logic. +/// Simple Overpass API client with retry and fallback logic. /// Single responsibility: Make requests, handle network errors, return data. class OverpassService { - static const String _endpoint = 'https://overpass-api.de/api/interpreter'; + static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter'; + static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter'; + static const _policy = ResiliencePolicy( + maxRetries: 3, + httpTimeout: Duration(seconds: 45), + ); + final http.Client _client; + /// Optional override endpoint. When null, uses defaultEndpoint (or settings override). + final String? _endpointOverride; - OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); + OverpassService({http.Client? client, String? endpoint}) + : _client = client ?? UserAgentClient(), + _endpointOverride = endpoint; + /// Resolve the primary endpoint: constructor override or default. + String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; - /// Fetch surveillance nodes from Overpass API with proper retry logic. + /// Fetch surveillance nodes from Overpass API with retry and fallback. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ required LatLngBounds bounds, required List profiles, - int maxRetries = 3, + int? maxRetries, }) async { if (profiles.isEmpty) return []; - + final query = _buildQuery(bounds, profiles); - - for (int attempt = 0; attempt <= maxRetries; attempt++) { - try { - debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles'); - - final response = await _client.post( - Uri.parse(_endpoint), - body: {'data': query}, - ).timeout(kOverpassQueryTimeout); - - if (response.statusCode == 200) { - return _parseResponse(response.body); - } - - // Check for specific error types - final errorBody = response.body; - - // Node limit error - caller should split area - if (response.statusCode == 400 && - (errorBody.contains('too many nodes') && errorBody.contains('50000'))) { - debugPrint('[OverpassService] Node limit exceeded, area should be split'); - throw NodeLimitError('Query exceeded 50k node limit'); - } - - // Timeout error - also try splitting (complex query) - if (errorBody.contains('timeout') || - errorBody.contains('runtime limit exceeded') || - errorBody.contains('Query timed out')) { - debugPrint('[OverpassService] Query timeout, area should be split'); - throw NodeLimitError('Query timed out - area too complex'); - } - - // Rate limit - throw immediately, don't retry - if (response.statusCode == 429 || - errorBody.contains('rate limited') || - errorBody.contains('too many requests')) { - debugPrint('[OverpassService] Rate limited by Overpass'); - throw RateLimitError('Rate limited by Overpass API'); - } - - // Other HTTP errors - retry with backoff - if (attempt < maxRetries) { - final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000)); - debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms'); - await Future.delayed(delay); - continue; - } - - throw NetworkError('HTTP ${response.statusCode}: $errorBody'); - - } catch (e) { - // Handle specific error types without retry - if (e is NodeLimitError || e is RateLimitError) { - rethrow; - } - - // Network/timeout errors - retry with backoff - if (attempt < maxRetries) { - final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000)); - debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms'); - await Future.delayed(delay); - continue; - } - - throw NetworkError('Network error after $maxRetries retries: $e'); + // Snapshot the endpoint once so fallback decision is consistent + final endpoint = _primaryEndpoint; + final canFallback = endpoint == defaultEndpoint; + + final effectivePolicy = maxRetries != null + ? ResiliencePolicy( + maxRetries: maxRetries, + httpTimeout: _policy.httpTimeout, + ) + : _policy; + + return executeWithFallback>( + primaryUrl: endpoint, + fallbackUrl: canFallback ? fallbackEndpoint : null, + execute: (url) => _attemptFetch(url, query), + classifyError: _classifyError, + policy: effectivePolicy, + ); + } + + /// Single POST + parse attempt (no retry logic — handled by executeWithFallback). + Future> _attemptFetch(String endpoint, String query) async { + debugPrint('[OverpassService] POST $endpoint'); + + try { + final response = await _client.post( + Uri.parse(endpoint), + body: {'data': query}, + ).timeout(_policy.httpTimeout); + + if (response.statusCode == 200) { + return _parseResponse(response.body); + } + + final errorBody = response.body; + + // Node limit error - caller should split area + if (response.statusCode == 400 && + (errorBody.contains('too many nodes') && errorBody.contains('50000'))) { + debugPrint('[OverpassService] Node limit exceeded, area should be split'); + throw NodeLimitError('Query exceeded 50k node limit'); } + + // Timeout error - also try splitting (complex query) + if (errorBody.contains('timeout') || + errorBody.contains('runtime limit exceeded') || + errorBody.contains('Query timed out')) { + debugPrint('[OverpassService] Query timeout, area should be split'); + throw NodeLimitError('Query timed out - area too complex'); + } + + // Rate limit + if (response.statusCode == 429 || + errorBody.contains('rate limited') || + errorBody.contains('too many requests')) { + debugPrint('[OverpassService] Rate limited by Overpass'); + throw RateLimitError('Rate limited by Overpass API'); + } + + throw NetworkError('HTTP ${response.statusCode}: $errorBody'); + } catch (e) { + if (e is NodeLimitError || e is RateLimitError || e is NetworkError) { + rethrow; + } + throw NetworkError('Network error: $e'); } - - throw NetworkError('Max retries exceeded'); } - + + static ErrorDisposition _classifyError(Object error) { + if (error is NodeLimitError) return ErrorDisposition.abort; + if (error is RateLimitError) return ErrorDisposition.fallback; + return ErrorDisposition.retry; + } + /// Build Overpass QL query for given bounds and profiles String _buildQuery(LatLngBounds bounds, List profiles) { final nodeClauses = profiles.map((profile) { @@ -107,7 +123,7 @@ class OverpassService { .where((entry) => entry.value.trim().isNotEmpty) .map((entry) => '["${entry.key}"="${entry.value}"]') .join(); - + return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});'; }).join('\n '); @@ -119,38 +135,38 @@ class OverpassService { out body; ( way(bn); - rel(bn); + rel(bn); ); out skel; '''; } - + /// Parse Overpass JSON response into OsmNode objects List _parseResponse(String responseBody) { final data = jsonDecode(responseBody) as Map; final elements = data['elements'] as List; - + final nodeElements = >[]; final constrainedNodeIds = {}; - + // First pass: collect surveillance nodes and identify constrained nodes for (final element in elements.whereType>()) { final type = element['type'] as String?; - + if (type == 'node') { nodeElements.add(element); } else if (type == 'way' || type == 'relation') { // Mark referenced nodes as constrained - final refs = element['nodes'] as List? ?? + final refs = element['nodes'] as List? ?? element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? []; - + for (final ref in refs) { final nodeId = ref is int ? ref : int.tryParse(ref.toString()); if (nodeId != null) constrainedNodeIds.add(nodeId); } } } - + // Second pass: create OsmNode objects final nodes = nodeElements.map((element) { final nodeId = element['id'] as int; @@ -161,7 +177,7 @@ out skel; isConstrained: constrainedNodeIds.contains(nodeId), ); }).toList(); - + debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained'); return nodes; } @@ -189,4 +205,4 @@ class NetworkError extends Error { NetworkError(this.message); @override String toString() => 'NetworkError: $message'; -} \ No newline at end of file +} diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index 3163f496..f6d7e0fa 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; -import '../dev_config.dart'; import 'http_client.dart'; +import 'service_policy.dart'; class RouteResult { final List waypoints; final double distanceMeters; final double durationSeconds; - + const RouteResult({ required this.waypoints, required this.distanceMeters, required this.durationSeconds, }); - + @override String toString() { return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)'; @@ -26,14 +26,27 @@ class RouteResult { } class RoutingService { - static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions'; + static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions'; + static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions'; + static const _policy = ResiliencePolicy( + maxRetries: 1, + httpTimeout: Duration(seconds: 30), + ); + final http.Client _client; + /// Optional override URL. When null, uses defaultUrl (or settings override). + final String? _baseUrlOverride; - RoutingService({http.Client? client}) : _client = client ?? UserAgentClient(); + RoutingService({http.Client? client, String? baseUrl}) + : _client = client ?? UserAgentClient(), + _baseUrlOverride = baseUrl; void close() => _client.close(); - // Calculate route between two points using alprwatch + /// Resolve the primary URL to use: constructor override or default. + String get _primaryUrl => _baseUrlOverride ?? defaultUrl; + + // Calculate route between two points Future calculateRoute({ required LatLng start, required LatLng end, @@ -53,8 +66,7 @@ class RoutingService { 'tags': tags, }; }).toList(); - - final uri = Uri.parse(_baseUrl); + final params = { 'start': { 'longitude': start.longitude, @@ -66,11 +78,26 @@ class RoutingService { }, 'avoidance_distance': avoidanceDistance, 'enabled_profiles': enabledProfiles, - 'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route + 'show_exclusion_zone': false, }; - - debugPrint('[RoutingService] alprwatch request: $uri $params'); - + + // Snapshot the URL once so fallback decision is consistent + final primaryUrl = _primaryUrl; + final canFallback = primaryUrl == defaultUrl; + + return executeWithFallback( + primaryUrl: primaryUrl, + fallbackUrl: canFallback ? fallbackUrl : null, + execute: (url) => _postRoute(url, params), + classifyError: _classifyError, + policy: _policy, + ); + } + + Future _postRoute(String url, Map params) async { + final uri = Uri.parse(url); + debugPrint('[RoutingService] POST $uri'); + try { final response = await _client.post( uri, @@ -78,7 +105,7 @@ class RoutingService { 'Content-Type': 'application/json' }, body: json.encode(params) - ).timeout(kNavigationRoutingTimeout); + ).timeout(_policy.httpTimeout); if (response.statusCode != 200) { if (kDebugMode) { @@ -91,24 +118,25 @@ class RoutingService { : body; debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated'); } - throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}', + statusCode: response.statusCode); } - + final data = json.decode(response.body) as Map; - debugPrint('[RoutingService] alprwatch response data: $data'); - - // Check alprwatch response status + debugPrint('[RoutingService] response data: $data'); + + // Check response status final ok = data['ok'] as bool? ?? false; if ( ! ok ) { final message = data['error'] as String? ?? 'Unknown routing error'; - throw RoutingException('alprwatch error: $message'); + throw RoutingException('API error: $message', isApiError: true); } - + final route = data['result']['route'] as Map?; if (route == null) { - throw RoutingException('No route found between these points'); + throw RoutingException('No route found between these points', isApiError: true); } - + final waypoints = (route['coordinates'] as List?) ?.map((inner) { final pair = inner as List; @@ -116,19 +144,19 @@ class RoutingService { final lng = (pair[0] as num).toDouble(); final lat = (pair[1] as num).toDouble(); return LatLng(lat, lng); - }).whereType().toList() ?? []; + }).whereType().toList() ?? []; final distance = (route['distance'] as num?)?.toDouble() ?? 0.0; final duration = (route['duration'] as num?)?.toDouble() ?? 0.0; - + final result = RouteResult( waypoints: waypoints, distanceMeters: distance, durationSeconds: duration, ); - + debugPrint('[RoutingService] Route calculated: $result'); return result; - + } catch (e) { debugPrint('[RoutingService] Route calculation failed: $e'); if (e is RoutingException) { @@ -138,13 +166,23 @@ class RoutingService { } } } + + static ErrorDisposition _classifyError(Object error) { + if (error is! RoutingException) return ErrorDisposition.retry; + if (error.isApiError) return ErrorDisposition.abort; + if (error.statusCode == 400) return ErrorDisposition.abort; + if (error.statusCode == 429) return ErrorDisposition.fallback; + return ErrorDisposition.retry; + } } class RoutingException implements Exception { final String message; - - const RoutingException(this.message); - + final int? statusCode; + final bool isApiError; + + const RoutingException(this.message, {this.statusCode, this.isApiError = false}); + @override String toString() => 'RoutingException: $message'; } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index acf8a246..461b3187 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -166,6 +166,7 @@ class ServicePolicyResolver { 'tile.openstreetmap.org': ServiceType.osmTileServer, 'nominatim.openstreetmap.org': ServiceType.nominatim, 'overpass-api.de': ServiceType.overpass, + 'overpass.deflock.org': ServiceType.overpass, 'taginfo.openstreetmap.org': ServiceType.tagInfo, 'tiles.virtualearth.net': ServiceType.bingTiles, 'api.mapbox.com': ServiceType.mapboxTiles, @@ -283,6 +284,90 @@ class ServicePolicyResolver { } } +/// How the retry/fallback engine should handle an error. +enum ErrorDisposition { + /// Stop immediately. Don't retry, don't try fallback. (400, business logic) + abort, + /// Don't retry same server, but DO try fallback endpoint. (429 rate limit) + fallback, + /// Retry with backoff against same server, then fallback if exhausted. (5xx, network) + retry, +} + +/// Retry and fallback configuration for resilient HTTP services. +class ResiliencePolicy { + final int maxRetries; + final Duration httpTimeout; + final Duration _retryBackoffBase; + final int _retryBackoffMaxMs; + + const ResiliencePolicy({ + this.maxRetries = 1, + this.httpTimeout = const Duration(seconds: 30), + Duration retryBackoffBase = const Duration(milliseconds: 200), + int retryBackoffMaxMs = 5000, + }) : _retryBackoffBase = retryBackoffBase, + _retryBackoffMaxMs = retryBackoffMaxMs; + + Duration retryDelay(int attempt) { + final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt)) + .clamp(0, _retryBackoffMaxMs); + return Duration(milliseconds: ms); + } +} + +/// Execute a request with retry and fallback logic. +/// +/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. +/// 2. On each failure, calls [classifyError] to determine disposition: +/// - [ErrorDisposition.abort]: rethrows immediately +/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available) +/// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted +/// 3. If [fallbackUrl] is non-null and the error is fallback-worthy, +/// repeats the retry loop against the fallback. +Future executeWithFallback({ + required String primaryUrl, + required String? fallbackUrl, + required Future Function(String url) execute, + required ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy policy = const ResiliencePolicy(), +}) async { + try { + return await _executeWithRetries(primaryUrl, execute, classifyError, policy); + } catch (e) { + final disposition = classifyError(e); + if (fallbackUrl == null || disposition == ErrorDisposition.abort) rethrow; + debugPrint('[Resilience] Primary failed ($e), trying fallback'); + } + return _executeWithRetries(fallbackUrl!, execute, classifyError, policy); +} + +Future _executeWithRetries( + String url, + Future Function(String url) execute, + ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy policy, +) async { + for (int attempt = 0; attempt <= policy.maxRetries; attempt++) { + try { + return await execute(url); + } catch (e) { + final disposition = classifyError(e); + if (disposition == ErrorDisposition.abort) rethrow; + if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback + // disposition == retry + if (attempt < policy.maxRetries) { + final delay = policy.retryDelay(attempt); + debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms'); + await Future.delayed(delay); + continue; + } + rethrow; // retries exhausted, let caller try fallback + } + } + throw StateError('Unreachable'); // loop always returns or throws +} + /// Reusable per-service rate limiter and concurrency controller. /// /// Enforces the rate limits and concurrency constraints defined in each diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index feb090d6..b257a31a 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier { _calculateRoute(); } - /// Calculate route using alprwatch + /// Calculate route via RoutingService (primary + fallback endpoints). void _calculateRoute() { if (_routeStart == null || _routeEnd == null) return; - debugPrint('[NavigationState] Calculating route with alprwatch...'); + debugPrint('[NavigationState] Calculating route...'); _isCalculating = true; _routingError = null; notifyListeners(); @@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier { _showingOverview = true; _provisionalPinLocation = null; // Hide provisional pin - debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}'); + debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}'); notifyListeners(); }).catchError((error) { diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index f7704ecc..15f278ce 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -36,7 +36,8 @@ void main() { setUp(() { mockClient = MockHttpClient(); - service = OverpassService(client: mockClient); + // Use explicit endpoint to avoid AppState dependency in tests + service = OverpassService(client: mockClient, endpoint: OverpassService.defaultEndpoint); }); /// Helper: stub a successful Overpass response with the given elements. @@ -246,7 +247,7 @@ void main() { stubErrorResponse( 400, 'Error: too many nodes (limit is 50000) in query'); - expect( + await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), @@ -256,7 +257,7 @@ void main() { test('response with "timeout" throws NodeLimitError', () async { stubErrorResponse(400, 'runtime error: timeout in query execution'); - expect( + await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), @@ -267,7 +268,7 @@ void main() { () async { stubErrorResponse(400, 'runtime limit exceeded'); - expect( + await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), @@ -277,7 +278,7 @@ void main() { test('HTTP 429 throws RateLimitError', () async { stubErrorResponse(429, 'Too Many Requests'); - expect( + await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), @@ -287,7 +288,7 @@ void main() { test('response with "rate limited" throws RateLimitError', () async { stubErrorResponse(503, 'You are rate limited'); - expect( + await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), @@ -298,7 +299,7 @@ void main() { () async { stubErrorResponse(500, 'Internal Server Error'); - expect( + await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), @@ -313,4 +314,178 @@ void main() { verifyNever(() => mockClient.post(any(), body: any(named: 'body'))); }); }); + + group('fallback behavior', () { + test('falls back to overpass-api.de on NetworkError after retries', () async { + int callCount = 0; + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'overpass.deflock.org') { + return http.Response('Internal Server Error', 500); + } + // Fallback succeeds + return http.Response( + jsonEncode({ + 'elements': [ + { + 'type': 'node', + 'id': 1, + 'lat': 38.9, + 'lon': -77.0, + 'tags': {'man_made': 'surveillance'}, + }, + ] + }), + 200, + ); + }); + + final nodes = await service.fetchNodes( + bounds: bounds, profiles: profiles, maxRetries: 0); + + expect(nodes, hasLength(1)); + // primary (1 attempt, 0 retries) + fallback (1 attempt) = 2 + expect(callCount, equals(2)); + }); + + test('does NOT fallback on NodeLimitError', () async { + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response( + 'Error: too many nodes (limit is 50000) in query', + 400, + )); + + await expectLater( + () => service.fetchNodes( + bounds: bounds, profiles: profiles, maxRetries: 0), + throwsA(isA()), + ); + + // Only one call — no fallback (abort disposition) + verify(() => mockClient.post(any(), body: any(named: 'body'))) + .called(1); + }); + + test('RateLimitError triggers fallback without retrying primary', () async { + int callCount = 0; + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'overpass.deflock.org') { + return http.Response('Too Many Requests', 429); + } + // Fallback succeeds + return http.Response( + jsonEncode({ + 'elements': [ + { + 'type': 'node', + 'id': 1, + 'lat': 38.9, + 'lon': -77.0, + 'tags': {'man_made': 'surveillance'}, + }, + ] + }), + 200, + ); + }); + + final nodes = await service.fetchNodes( + bounds: bounds, profiles: profiles, maxRetries: 2); + + expect(nodes, hasLength(1)); + // 1 primary (no retry on fallback disposition) + 1 fallback = 2 + expect(callCount, equals(2)); + }); + + test('primary fails then fallback also fails -> error propagated', () async { + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => + http.Response('Internal Server Error', 500)); + + await expectLater( + () => service.fetchNodes( + bounds: bounds, profiles: profiles, maxRetries: 0), + throwsA(isA()), + ); + + // primary + fallback + verify(() => mockClient.post(any(), body: any(named: 'body'))) + .called(2); + }); + + test('does NOT fallback when using custom endpoint', () async { + final customService = OverpassService( + client: mockClient, + endpoint: 'https://custom.example.com/api/interpreter', + ); + + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => + http.Response('Internal Server Error', 500)); + + await expectLater( + () => customService.fetchNodes( + bounds: bounds, profiles: profiles, maxRetries: 0), + throwsA(isA()), + ); + + // Only one call - no fallback with custom endpoint + verify(() => mockClient.post(any(), body: any(named: 'body'))) + .called(1); + }); + + test('retries exhaust before fallback kicks in', () async { + int callCount = 0; + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'overpass.deflock.org') { + return http.Response('Server Error', 500); + } + // Fallback succeeds + return http.Response( + jsonEncode({ + 'elements': [ + { + 'type': 'node', + 'id': 1, + 'lat': 38.9, + 'lon': -77.0, + 'tags': {'man_made': 'surveillance'}, + }, + ] + }), + 200, + ); + }); + + final nodes = await service.fetchNodes( + bounds: bounds, profiles: profiles, maxRetries: 2); + + expect(nodes, hasLength(1)); + // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 + expect(callCount, equals(4)); + }); + }); + + group('default endpoints', () { + test('default endpoint is overpass.deflock.org', () { + expect(OverpassService.defaultEndpoint, + equals('https://overpass.deflock.org/api/interpreter')); + }); + + test('fallback endpoint is overpass-api.de', () { + expect(OverpassService.fallbackEndpoint, + equals('https://overpass-api.de/api/interpreter')); + }); + }); } diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart index 757000e2..7bc71071 100644 --- a/test/services/routing_service_test.dart +++ b/test/services/routing_service_test.dart @@ -34,13 +34,38 @@ void main() { mockAppState = MockAppState(); AppState.instance = mockAppState; - service = RoutingService(client: mockClient); + // Use a fixed baseUrl so tests don't try to resolve AppState settings + service = RoutingService(client: mockClient, baseUrl: RoutingService.defaultUrl); }); tearDown(() { AppState.instance = MockAppState(); }); + /// Helper: stub a successful routing response + void stubSuccessResponse() { + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [ + [-77.0, 38.9], + [-77.1, 39.0], + ], + 'distance': 1000.0, + 'duration': 600.0, + }, + }, + }), + 200, + )); + } + group('RoutingService', () { test('empty tags are filtered from request body', () async { // Profile with empty tag values (like builtin-flock has camera:mount: '') @@ -57,29 +82,7 @@ void main() { ]; when(() => mockAppState.enabledProfiles).thenReturn(profiles); - // Capture the request body - when(() => mockClient.post( - any(), - headers: any(named: 'headers'), - body: any(named: 'body'), - )).thenAnswer((invocation) async { - return http.Response( - json.encode({ - 'ok': true, - 'result': { - 'route': { - 'coordinates': [ - [-77.0, 38.9], - [-77.1, 39.0], - ], - 'distance': 1000.0, - 'duration': 600.0, - }, - }, - }), - 200, - ); - }); + stubSuccessResponse(); await service.calculateRoute(start: start, end: end); @@ -147,7 +150,7 @@ void main() { reasonPhrase: 'Bad Request', )); - expect( + await expectLater( () => service.calculateRoute(start: start, end: end), throwsA(isA().having( (e) => e.message, @@ -166,7 +169,7 @@ void main() { body: any(named: 'body'), )).thenThrow(http.ClientException('Connection refused')); - expect( + await expectLater( () => service.calculateRoute(start: start, end: end), throwsA(isA().having( (e) => e.message, @@ -176,7 +179,7 @@ void main() { ); }); - test('API-level error surfaces alprwatch message', () async { + test('API-level error surfaces message', () async { when(() => mockAppState.enabledProfiles).thenReturn([]); when(() => mockClient.post( @@ -191,7 +194,7 @@ void main() { 200, )); - expect( + await expectLater( () => service.calculateRoute(start: start, end: end), throwsA(isA().having( (e) => e.message, @@ -201,4 +204,274 @@ void main() { ); }); }); + + group('fallback behavior', () { + test('falls back to secondary on server error (500) after retries', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'api.dontgetflocked.com') { + return http.Response('Internal Server Error', 500, + reasonPhrase: 'Internal Server Error'); + } + // Fallback succeeds + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [ + [-77.0, 38.9], + [-77.1, 39.0], + ], + 'distance': 5000.0, + 'duration': 300.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(5000.0)); + // 2 primary attempts (1 + 1 retry) + 1 fallback = 3 + expect(callCount, equals(3)); + }); + + test('falls back on 502 (GraphHopper unavailable) after retries', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + if (uri.host == 'api.dontgetflocked.com') { + return http.Response('Bad Gateway', 502, reasonPhrase: 'Bad Gateway'); + } + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [[-77.0, 38.9]], + 'distance': 100.0, + 'duration': 60.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(100.0)); + // 2 primary attempts + 1 fallback = 3 + expect(callCount, equals(3)); + }); + + test('falls back on network error after retries', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + if (uri.host == 'api.dontgetflocked.com') { + throw http.ClientException('Connection refused'); + } + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [[-77.0, 38.9]], + 'distance': 100.0, + 'duration': 60.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(100.0)); + // 2 primary attempts + 1 fallback = 3 + expect(callCount, equals(3)); + }); + + test('429 triggers fallback without retrying primary', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + if (uri.host == 'api.dontgetflocked.com') { + return http.Response('Too Many Requests', 429, + reasonPhrase: 'Too Many Requests'); + } + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [[-77.0, 38.9]], + 'distance': 200.0, + 'duration': 120.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(200.0)); + // 1 primary (no retry on 429/fallback disposition) + 1 fallback = 2 + expect(callCount, equals(2)); + }); + + test('does NOT fallback on 400 (validation error)', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Bad Request: missing start', 400, + reasonPhrase: 'Bad Request')); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.statusCode, 'statusCode', 400)), + ); + + // Only one call — no retry, no fallback (abort disposition) + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + }); + + test('does NOT fallback on API-level business logic errors', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + json.encode({ + 'ok': false, + 'error': 'No route found', + }), + 200, + )); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.isApiError, 'isApiError', true)), + ); + + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + }); + + test('primary fails then fallback also fails -> error propagated', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Internal Server Error', 500, + reasonPhrase: 'Internal Server Error')); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.statusCode, 'statusCode', 500)), + ); + + // 2 primary attempts + 2 fallback attempts = 4 + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(4); + }); + + test('does NOT fallback when using custom baseUrl', () async { + final customService = RoutingService( + client: mockClient, + baseUrl: 'https://custom.example.com/route', + ); + + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Service Unavailable', 503, + reasonPhrase: 'Service Unavailable')); + + await expectLater( + () => customService.calculateRoute(start: start, end: end), + throwsA(isA()), + ); + + // 2 attempts (1 + 1 retry), no fallback with custom URL + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(2); + }); + }); + + group('RoutingException', () { + test('statusCode is preserved', () { + const e = RoutingException('test', statusCode: 502); + expect(e.statusCode, 502); + expect(e.isApiError, false); + }); + + test('isApiError flag works', () { + const e = RoutingException('test', isApiError: true); + expect(e.isApiError, true); + expect(e.statusCode, isNull); + }); + }); } diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index bfe31e41..a816ec46 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -423,4 +423,176 @@ void main() { expect(policy.attributionUrl, 'https://example.com/license'); }); }); + + group('ResiliencePolicy', () { + test('retryDelay uses exponential backoff', () { + const policy = ResiliencePolicy( + retryBackoffBase: Duration(milliseconds: 100), + retryBackoffMaxMs: 2000, + ); + expect(policy.retryDelay(0), const Duration(milliseconds: 100)); + expect(policy.retryDelay(1), const Duration(milliseconds: 200)); + expect(policy.retryDelay(2), const Duration(milliseconds: 400)); + }); + + test('retryDelay clamps to max', () { + const policy = ResiliencePolicy( + retryBackoffBase: Duration(milliseconds: 1000), + retryBackoffMaxMs: 3000, + ); + expect(policy.retryDelay(0), const Duration(milliseconds: 1000)); + expect(policy.retryDelay(1), const Duration(milliseconds: 2000)); + expect(policy.retryDelay(2), const Duration(milliseconds: 3000)); // clamped + expect(policy.retryDelay(10), const Duration(milliseconds: 3000)); // clamped + }); + }); + + group('executeWithFallback', () { + const policy = ResiliencePolicy( + maxRetries: 2, + retryBackoffBase: Duration.zero, // no delay in tests + ); + + test('abort error stops immediately, no fallback', () async { + int callCount = 0; + + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + callCount++; + throw Exception('bad request'); + }, + classifyError: (_) => ErrorDisposition.abort, + policy: policy, + ), + throwsA(isA()), + ); + + expect(callCount, 1); // no retries, no fallback + }); + + test('fallback error skips retries, goes to fallback', () async { + final urlsSeen = []; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + urlsSeen.add(url); + if (url.contains('primary')) { + throw Exception('rate limited'); + } + return Future.value('ok from fallback'); + }, + classifyError: (_) => ErrorDisposition.fallback, + policy: policy, + ); + + expect(result, 'ok from fallback'); + // 1 primary (no retries) + 1 fallback = 2 + expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']); + }); + + test('retry error retries N times then falls back', () async { + final urlsSeen = []; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + urlsSeen.add(url); + if (url.contains('primary')) { + throw Exception('server error'); + } + return Future.value('ok from fallback'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ); + + expect(result, 'ok from fallback'); + // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 + expect(urlsSeen.where((u) => u.contains('primary')).length, 3); + expect(urlsSeen.where((u) => u.contains('fallback')).length, 1); + }); + + test('no fallback URL rethrows after retries', () async { + int callCount = 0; + + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: null, + execute: (url) { + callCount++; + throw Exception('server error'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ), + throwsA(isA()), + ); + + // 3 attempts (1 + 2 retries), then rethrow + expect(callCount, 3); + }); + + test('both fail propagates last error', () async { + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + if (url.contains('fallback')) { + throw Exception('fallback also failed'); + } + throw Exception('primary failed'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('fallback also failed'))), + ); + }); + + test('success on first try returns immediately', () async { + int callCount = 0; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + callCount++; + return Future.value('success'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ); + + expect(result, 'success'); + expect(callCount, 1); + }); + + test('success after retry does not try fallback', () async { + int callCount = 0; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + callCount++; + if (callCount == 1) throw Exception('transient'); + return Future.value('recovered'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ); + + expect(result, 'recovered'); + expect(callCount, 2); // 1 fail + 1 success, no fallback + }); + }); } From 07112bed74b733c2a5525799a32e3f53f3da8935 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 22:10:56 -0600 Subject: [PATCH 2/6] Treat all 4xx as abort except 429, fix doc comment Co-Authored-By: Claude Opus 4.6 --- lib/services/routing_service.dart | 7 +++++-- lib/services/service_policy.dart | 2 +- test/services/routing_service_test.dart | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index f6d7e0fa..affd4ce1 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -170,8 +170,11 @@ class RoutingService { static ErrorDisposition _classifyError(Object error) { if (error is! RoutingException) return ErrorDisposition.retry; if (error.isApiError) return ErrorDisposition.abort; - if (error.statusCode == 400) return ErrorDisposition.abort; - if (error.statusCode == 429) return ErrorDisposition.fallback; + final status = error.statusCode; + if (status != null && status >= 400 && status < 500) { + if (status == 429) return ErrorDisposition.fallback; + return ErrorDisposition.abort; + } return ErrorDisposition.retry; } } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 461b3187..242d1dfc 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -152,7 +152,7 @@ class ServicePolicy { 'attributionUrl: $attributionUrl)'; } -/// Resolves URLs and tile providers to their applicable [ServicePolicy]. +/// Resolves service URLs to their applicable [ServicePolicy]. /// /// Built-in patterns cover all OSMF official services and common third-party /// tile providers. Custom overrides can be registered for self-hosted endpoints diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart index 7bc71071..6b9bcc46 100644 --- a/test/services/routing_service_test.dart +++ b/test/services/routing_service_test.dart @@ -378,6 +378,31 @@ void main() { )).called(1); }); + test('does NOT fallback on 403 (all 4xx except 429 abort)', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Forbidden', 403, + reasonPhrase: 'Forbidden')); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.statusCode, 'statusCode', 403)), + ); + + // Only one call — no retry, no fallback (abort disposition) + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + }); + test('does NOT fallback on API-level business logic errors', () async { when(() => mockAppState.enabledProfiles).thenReturn([]); From 30b9ff06f7881851c27f6bbde7440f285907bd45 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 22:11:05 -0600 Subject: [PATCH 3/6] Add fallback and null-URL tests for executeWithFallback Co-Authored-By: Claude Opus 4.6 --- test/services/service_policy_test.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index a816ec46..f1cc0a5a 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -539,6 +539,27 @@ void main() { expect(callCount, 3); }); + test('fallback disposition with no fallback URL rethrows immediately', () async { + int callCount = 0; + + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: null, + execute: (url) { + callCount++; + throw Exception('rate limited'); + }, + classifyError: (_) => ErrorDisposition.fallback, + policy: policy, + ), + throwsA(isA()), + ); + + // Only 1 attempt — fallback disposition skips retries, and no fallback URL + expect(callCount, 1); + }); + test('both fail propagates last error', () async { await expectLater( () => executeWithFallback( From 724dee05efb271152f2428f44d637da23ea01e35 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 00:03:34 -0700 Subject: [PATCH 4/6] Fix analyzer warning: restructure null check for type promotion Split the combined null/abort guard so Dart's flow analysis promotes fallbackUrl to non-null, eliminating the unnecessary_non_null_assertion. Co-Authored-By: Claude Opus 4.6 --- lib/services/service_policy.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 242d1dfc..63324641 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -336,10 +336,11 @@ Future executeWithFallback({ return await _executeWithRetries(primaryUrl, execute, classifyError, policy); } catch (e) { final disposition = classifyError(e); - if (fallbackUrl == null || disposition == ErrorDisposition.abort) rethrow; + if (disposition == ErrorDisposition.abort) rethrow; + if (fallbackUrl == null) rethrow; debugPrint('[Resilience] Primary failed ($e), trying fallback'); + return _executeWithRetries(fallbackUrl, execute, classifyError, policy); } - return _executeWithRetries(fallbackUrl!, execute, classifyError, policy); } Future _executeWithRetries( From 8584a1833f336a69d930f794e03a4099b9248b47 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 22:12:51 -0600 Subject: [PATCH 5/6] Add multi-endpoint service registry with per-endpoint policy overrides Evolve from hard-coded primary/fallback endpoint pairs to a user-configurable service registry. Each endpoint carries its own name, URL, enabled state, and optional retry/timeout overrides. - ServiceEndpoint model with JSON serialization and sensible defaults - ServiceRegistry generic ordered list with SharedPreferences persistence - executeWithEndpointList() iterates enabled endpoints with per-endpoint policy - OverpassService and RoutingService accept List - SettingsState uses ServiceRegistry instances with migration from old format - API Endpoints UI section for managing endpoint registries Co-Authored-By: Claude Opus 4.6 --- lib/app_state.dart | 12 + lib/localizations/de.json | 18 +- lib/localizations/en.json | 18 +- lib/localizations/es.json | 18 +- lib/localizations/fr.json | 18 +- lib/localizations/it.json | 18 +- lib/localizations/nl.json | 18 +- lib/localizations/pl.json | 18 +- lib/localizations/pt.json | 18 +- lib/localizations/tr.json | 18 +- lib/localizations/uk.json | 18 +- lib/localizations/zh.json | 18 +- lib/models/service_endpoint.dart | 116 ++++++ lib/models/service_registry_entry.dart | 22 ++ lib/screens/advanced_settings_screen.dart | 3 + .../sections/api_endpoints_section.dart | 280 ++++++++++++++ lib/services/overpass_service.dart | 33 +- lib/services/routing_service.dart | 33 +- lib/services/service_policy.dart | 65 +++- lib/state/service_registry.dart | 133 +++++++ lib/state/settings_state.dart | 89 ++++- pubspec.lock | 16 +- test/models/service_endpoint_test.dart | 83 ++++ test/services/overpass_service_test.dart | 14 +- test/services/routing_service_test.dart | 16 +- test/services/service_policy_test.dart | 207 ++++++++++ test/state/service_registry_test.dart | 362 ++++++++++++++++++ test/state/settings_state_test.dart | 155 ++++++++ 28 files changed, 1773 insertions(+), 64 deletions(-) create mode 100644 lib/models/service_endpoint.dart create mode 100644 lib/models/service_registry_entry.dart create mode 100644 lib/screens/settings/sections/api_endpoints_section.dart create mode 100644 lib/state/service_registry.dart create mode 100644 test/models/service_endpoint_test.dart create mode 100644 test/state/service_registry_test.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index a208d638..f71644ed 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -11,6 +11,7 @@ import 'models/operator_profile.dart'; import 'models/osm_node.dart'; import 'models/pending_upload.dart'; import 'models/suspected_location.dart'; +import 'models/service_endpoint.dart'; import 'models/tile_provider.dart'; import 'models/search_result.dart'; import 'services/offline_area_service.dart'; @@ -31,6 +32,7 @@ import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; import 'state/search_state.dart'; import 'state/session_state.dart'; +import 'state/service_registry.dart'; import 'state/settings_state.dart'; import 'state/suspected_location_state.dart'; import 'state/upload_queue_state.dart'; @@ -167,6 +169,14 @@ class AppState extends ChangeNotifier { bool get hasUnreadMessages => _messagesState.hasUnreadMessages; bool get isCheckingMessages => _messagesState.isChecking; + // API endpoint settings + List get routingEndpoints => _settingsState.routingEndpoints; + List get enabledRoutingEndpoints => _settingsState.enabledRoutingEndpoints; + List get overpassEndpoints => _settingsState.overpassEndpoints; + List get enabledOverpassEndpoints => _settingsState.enabledOverpassEndpoints; + ServiceRegistry get routingRegistry => _settingsState.routingRegistry; + ServiceRegistry get overpassRegistry => _settingsState.overpassRegistry; + // Tile provider state List get tileProviders => _settingsState.tileProviders; TileType? get selectedTileType => _settingsState.selectedTileType; @@ -753,6 +763,8 @@ class AppState extends ChangeNotifier { await _settingsState.setDistanceUnit(unit); } + // Endpoint registry methods are accessed via routingRegistry/overpassRegistry directly + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index cd9bdae6..482cdb5a 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -74,7 +74,23 @@ "advancedSettings": "Erweiterte Einstellungen", "advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen", "proximityAlerts": "Näherungswarnungen", - "networkStatusIndicator": "Netzwerkstatus-Anzeige" + "networkStatusIndicator": "Netzwerkstatus-Anzeige", + "apiEndpoints": "API-Endpunkte", + "apiEndpointsDescription": "Prioritätsreihenfolge der API-Endpunkte konfigurieren, benutzerdefinierte Endpunkte hinzufügen oder auf Standard zurücksetzen.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knotenquelle", + "invalidUrl": "Geben Sie eine gültige HTTPS-URL ein", + "addEndpoint": "Endpunkt hinzufügen", + "editEndpoint": "Endpunkt bearbeiten", + "resetToDefaults": "Auf Standard zurücksetzen", + "endpointName": "Endpunktname", + "endpointUrl": "Endpunkt-URL", + "endpointMaxRetries": "Max. Wiederholungen", + "endpointTimeout": "Timeout (Sekunden)", + "endpointEnabled": "Aktiviert", + "noEnabledEndpoints": "Mindestens ein Endpunkt muss aktiviert bleiben", + "confirmResetEndpoints": "Alle Endpunkte auf Standardkonfiguration zurücksetzen?", + "builtInEndpoint": "Eingebaut" }, "proximityAlerts": { "getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 7f7023b4..53be06ca 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -111,7 +111,23 @@ "advancedSettings": "Advanced Settings", "advancedSettingsSubtitle": "Performance, alerts, and tile provider settings", "proximityAlerts": "Proximity Alerts", - "networkStatusIndicator": "Network Status Indicator" + "networkStatusIndicator": "Network Status Indicator", + "apiEndpoints": "API Endpoints", + "apiEndpointsDescription": "Configure API endpoint priority order, add custom endpoints, or reset to defaults.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Node Source", + "invalidUrl": "Enter a valid HTTPS URL", + "addEndpoint": "Add Endpoint", + "editEndpoint": "Edit Endpoint", + "resetToDefaults": "Reset to Defaults", + "endpointName": "Endpoint Name", + "endpointUrl": "Endpoint URL", + "endpointMaxRetries": "Max Retries", + "endpointTimeout": "Timeout (seconds)", + "endpointEnabled": "Enabled", + "noEnabledEndpoints": "At least one endpoint must remain enabled", + "confirmResetEndpoints": "Reset all endpoints to their default configuration?", + "builtInEndpoint": "Built-in" }, "proximityAlerts": { "getNotified": "Get notified when approaching surveillance devices", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 423ca6d8..2da6400c 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -111,7 +111,23 @@ "advancedSettings": "Configuración Avanzada", "advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas", "proximityAlerts": "Alertas de Proximidad", - "networkStatusIndicator": "Indicador de Estado de Red" + "networkStatusIndicator": "Indicador de Estado de Red", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar el orden de prioridad de los endpoints de API, agregar endpoints personalizados o restablecer valores predeterminados.", + "apiEndpointRouting": "Enrutamiento", + "apiEndpointNodeSource": "Fuente de nodos", + "invalidUrl": "Ingrese una URL HTTPS válida", + "addEndpoint": "Agregar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Restablecer Valores Predeterminados", + "endpointName": "Nombre del Endpoint", + "endpointUrl": "URL del Endpoint", + "endpointMaxRetries": "Máx. Reintentos", + "endpointTimeout": "Tiempo de espera (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Al menos un endpoint debe permanecer habilitado", + "confirmResetEndpoints": "¿Restablecer todos los endpoints a su configuración predeterminada?", + "builtInEndpoint": "Incorporado" }, "proximityAlerts": { "getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index b314a5b0..a975f126 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -111,7 +111,23 @@ "advancedSettings": "Paramètres Avancés", "advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles", "proximityAlerts": "Alertes de Proximité", - "networkStatusIndicator": "Indicateur de Statut Réseau" + "networkStatusIndicator": "Indicateur de Statut Réseau", + "apiEndpoints": "Points d'accès API", + "apiEndpointsDescription": "Configurer l'ordre de priorité des points d'accès API, ajouter des points d'accès personnalisés ou réinitialiser aux valeurs par défaut.", + "apiEndpointRouting": "Routage", + "apiEndpointNodeSource": "Source des nœuds", + "invalidUrl": "Entrez une URL HTTPS valide", + "addEndpoint": "Ajouter un Point d'accès", + "editEndpoint": "Modifier le Point d'accès", + "resetToDefaults": "Réinitialiser aux Valeurs par Défaut", + "endpointName": "Nom du Point d'accès", + "endpointUrl": "URL du Point d'accès", + "endpointMaxRetries": "Max. Tentatives", + "endpointTimeout": "Délai d'attente (secondes)", + "endpointEnabled": "Activé", + "noEnabledEndpoints": "Au moins un point d'accès doit rester activé", + "confirmResetEndpoints": "Réinitialiser tous les points d'accès à leur configuration par défaut ?", + "builtInEndpoint": "Intégré" }, "proximityAlerts": { "getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 6602d76c..7d536688 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -111,7 +111,23 @@ "advancedSettings": "Impostazioni Avanzate", "advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere", "proximityAlerts": "Avvisi di Prossimità", - "networkStatusIndicator": "Indicatore di Stato di Rete" + "networkStatusIndicator": "Indicatore di Stato di Rete", + "apiEndpoints": "Endpoint API", + "apiEndpointsDescription": "Configura l'ordine di priorità degli endpoint API, aggiungi endpoint personalizzati o ripristina i valori predefiniti.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Fonte dei nodi", + "invalidUrl": "Inserisci un URL HTTPS valido", + "addEndpoint": "Aggiungi Endpoint", + "editEndpoint": "Modifica Endpoint", + "resetToDefaults": "Ripristina Predefiniti", + "endpointName": "Nome Endpoint", + "endpointUrl": "URL Endpoint", + "endpointMaxRetries": "Max Tentativi", + "endpointTimeout": "Timeout (secondi)", + "endpointEnabled": "Abilitato", + "noEnabledEndpoints": "Almeno un endpoint deve rimanere abilitato", + "confirmResetEndpoints": "Ripristinare tutti gli endpoint alla configurazione predefinita?", + "builtInEndpoint": "Integrato" }, "proximityAlerts": { "getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index f9d0cd55..48126a66 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -111,7 +111,23 @@ "advancedSettings": "Geavanceerde Instellingen", "advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen", "proximityAlerts": "Nabijheids Waarschuwingen", - "networkStatusIndicator": "Netwerk Status Indicator" + "networkStatusIndicator": "Netwerk Status Indicator", + "apiEndpoints": "API-eindpunten", + "apiEndpointsDescription": "Configureer de prioriteitsvolgorde van API-eindpunten, voeg aangepaste eindpunten toe of reset naar standaard.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knooppuntbron", + "invalidUrl": "Voer een geldige HTTPS-URL in", + "addEndpoint": "Eindpunt Toevoegen", + "editEndpoint": "Eindpunt Bewerken", + "resetToDefaults": "Standaardwaarden Herstellen", + "endpointName": "Eindpuntnaam", + "endpointUrl": "Eindpunt-URL", + "endpointMaxRetries": "Max. Pogingen", + "endpointTimeout": "Timeout (seconden)", + "endpointEnabled": "Ingeschakeld", + "noEnabledEndpoints": "Minstens één eindpunt moet ingeschakeld blijven", + "confirmResetEndpoints": "Alle eindpunten terugzetten naar standaardconfiguratie?", + "builtInEndpoint": "Ingebouwd" }, "proximityAlerts": { "getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 76fc22b4..a892c1e7 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -111,7 +111,23 @@ "advancedSettings": "Ustawienia Zaawansowane", "advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków", "proximityAlerts": "Alerty Bliskości", - "networkStatusIndicator": "Wskaźnik Stanu Sieci" + "networkStatusIndicator": "Wskaźnik Stanu Sieci", + "apiEndpoints": "Punkty końcowe API", + "apiEndpointsDescription": "Skonfiguruj kolejność priorytetów punktów końcowych API, dodaj niestandardowe punkty końcowe lub przywróć wartości domyślne.", + "apiEndpointRouting": "Trasowanie", + "apiEndpointNodeSource": "Źródło węzłów", + "invalidUrl": "Wprowadź prawidłowy URL HTTPS", + "addEndpoint": "Dodaj Punkt Końcowy", + "editEndpoint": "Edytuj Punkt Końcowy", + "resetToDefaults": "Przywróć Domyślne", + "endpointName": "Nazwa Punktu Końcowego", + "endpointUrl": "URL Punktu Końcowego", + "endpointMaxRetries": "Maks. Powtórzeń", + "endpointTimeout": "Limit czasu (sekundy)", + "endpointEnabled": "Włączony", + "noEnabledEndpoints": "Co najmniej jeden punkt końcowy musi pozostać włączony", + "confirmResetEndpoints": "Przywrócić wszystkie punkty końcowe do konfiguracji domyślnej?", + "builtInEndpoint": "Wbudowany" }, "proximityAlerts": { "getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 8366a549..49dd5827 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -111,7 +111,23 @@ "advancedSettings": "Configurações Avançadas", "advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas", "proximityAlerts": "Alertas de Proximidade", - "networkStatusIndicator": "Indicador de Status de Rede" + "networkStatusIndicator": "Indicador de Status de Rede", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar a ordem de prioridade dos endpoints de API, adicionar endpoints personalizados ou redefinir para os padrões.", + "apiEndpointRouting": "Roteamento", + "apiEndpointNodeSource": "Fonte de nós", + "invalidUrl": "Insira um URL HTTPS válido", + "addEndpoint": "Adicionar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Redefinir para Padrões", + "endpointName": "Nome do Endpoint", + "endpointUrl": "URL do Endpoint", + "endpointMaxRetries": "Máx. Tentativas", + "endpointTimeout": "Tempo limite (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Pelo menos um endpoint deve permanecer habilitado", + "confirmResetEndpoints": "Redefinir todos os endpoints para a configuração padrão?", + "builtInEndpoint": "Integrado" }, "proximityAlerts": { "getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 06fc9adf..6f5b4276 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -111,7 +111,23 @@ "advancedSettings": "Gelişmiş Ayarlar", "advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları", "proximityAlerts": "Yakınlık Uyarıları", - "networkStatusIndicator": "Ağ Durumu Göstergesi" + "networkStatusIndicator": "Ağ Durumu Göstergesi", + "apiEndpoints": "API Uç Noktaları", + "apiEndpointsDescription": "API uç noktası öncelik sırasını yapılandırın, özel uç noktalar ekleyin veya varsayılanlara sıfırlayın.", + "apiEndpointRouting": "Yönlendirme", + "apiEndpointNodeSource": "Düğüm kaynağı", + "invalidUrl": "Geçerli bir HTTPS URL'si girin", + "addEndpoint": "Uç Nokta Ekle", + "editEndpoint": "Uç Noktayı Düzenle", + "resetToDefaults": "Varsayılanlara Sıfırla", + "endpointName": "Uç Nokta Adı", + "endpointUrl": "Uç Nokta URL'si", + "endpointMaxRetries": "Maks. Yeniden Deneme", + "endpointTimeout": "Zaman Aşımı (saniye)", + "endpointEnabled": "Etkin", + "noEnabledEndpoints": "En az bir uç nokta etkin kalmalıdır", + "confirmResetEndpoints": "Tüm uç noktalar varsayılan yapılandırmaya sıfırlansın mı?", + "builtInEndpoint": "Yerleşik" }, "proximityAlerts": { "getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 499f1a24..5e3b82ef 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -111,7 +111,23 @@ "advancedSettings": "Розширені Налаштування", "advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток", "proximityAlerts": "Сповіщення Про Близькість", - "networkStatusIndicator": "Індикатор Стану Мережі" + "networkStatusIndicator": "Індикатор Стану Мережі", + "apiEndpoints": "Кінцеві точки API", + "apiEndpointsDescription": "Налаштувати порядок пріоритету кінцевих точок API, додати власні кінцеві точки або скинути до стандартних.", + "apiEndpointRouting": "Маршрутизація", + "apiEndpointNodeSource": "Джерело вузлів", + "invalidUrl": "Введіть дійсну HTTPS URL-адресу", + "addEndpoint": "Додати Кінцеву Точку", + "editEndpoint": "Редагувати Кінцеву Точку", + "resetToDefaults": "Скинути до Стандартних", + "endpointName": "Назва Кінцевої Точки", + "endpointUrl": "URL Кінцевої Точки", + "endpointMaxRetries": "Макс. Спроб", + "endpointTimeout": "Тайм-аут (секунди)", + "endpointEnabled": "Увімкнено", + "noEnabledEndpoints": "Принаймні одна кінцева точка повинна залишатися увімкненою", + "confirmResetEndpoints": "Скинути всі кінцеві точки до стандартної конфігурації?", + "builtInEndpoint": "Вбудована" }, "proximityAlerts": { "getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 00695588..409e972d 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -111,7 +111,23 @@ "advancedSettings": "高级设置", "advancedSettingsSubtitle": "性能、警报和地图提供商设置", "proximityAlerts": "邻近警报", - "networkStatusIndicator": "网络状态指示器" + "networkStatusIndicator": "网络状态指示器", + "apiEndpoints": "API 端点", + "apiEndpointsDescription": "配置 API 端点优先级顺序,添加自定义端点或重置为默认值。", + "apiEndpointRouting": "路由", + "apiEndpointNodeSource": "节点来源", + "invalidUrl": "请输入有效的 HTTPS URL", + "addEndpoint": "添加端点", + "editEndpoint": "编辑端点", + "resetToDefaults": "重置为默认值", + "endpointName": "端点名称", + "endpointUrl": "端点 URL", + "endpointMaxRetries": "最大重试次数", + "endpointTimeout": "超时时间(秒)", + "endpointEnabled": "已启用", + "noEnabledEndpoints": "至少需要保留一个已启用的端点", + "confirmResetEndpoints": "将所有端点重置为默认配置?", + "builtInEndpoint": "内置" }, "proximityAlerts": { "getNotified": "接近监控设备时接收通知", diff --git a/lib/models/service_endpoint.dart b/lib/models/service_endpoint.dart new file mode 100644 index 00000000..1b94dd79 --- /dev/null +++ b/lib/models/service_endpoint.dart @@ -0,0 +1,116 @@ +import 'service_registry_entry.dart'; + +/// A configurable API endpoint with optional resilience overrides. +/// +/// Used by [RoutingService] and [OverpassService] as entries in +/// their priority-ordered endpoint lists. +class ServiceEndpoint implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + + /// The endpoint URL (must be HTTPS). + final String url; + + @override + final bool enabled; + @override + final bool isBuiltIn; + + /// Override the service's default max retry count. Null = use default. + final int? maxRetries; + + /// Override the service's default HTTP timeout in seconds. Null = use default. + final int? timeoutSeconds; + + const ServiceEndpoint({ + required this.id, + required this.name, + required this.url, + this.enabled = true, + this.isBuiltIn = false, + this.maxRetries, + this.timeoutSeconds, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'url': url, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + if (maxRetries != null) 'maxRetries': maxRetries, + if (timeoutSeconds != null) 'timeoutSeconds': timeoutSeconds, + }; + + static ServiceEndpoint fromJson(Map json) => ServiceEndpoint( + id: json['id'] as String, + name: json['name'] as String, + url: json['url'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + maxRetries: json['maxRetries'] as int?, + timeoutSeconds: json['timeoutSeconds'] as int?, + ); + + ServiceEndpoint copyWith({ + String? id, + String? name, + String? url, + bool? enabled, + bool? isBuiltIn, + int? maxRetries, + int? timeoutSeconds, + }) => ServiceEndpoint( + id: id ?? this.id, + name: name ?? this.name, + url: url ?? this.url, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn ?? this.isBuiltIn, + maxRetries: maxRetries ?? this.maxRetries, + timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ServiceEndpoint && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Default routing endpoints. +class DefaultServiceEndpoints { + static List routing() => const [ + ServiceEndpoint( + id: 'routing-deflock', + name: 'Deflock Primary', + url: 'https://api.dontgetflocked.com/api/v1/deflock/directions', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'routing-alprwatch', + name: 'ALPRWatch Fallback', + url: 'https://alprwatch.org/api/v1/deflock/directions', + isBuiltIn: true, + ), + ]; + + static List overpass() => const [ + ServiceEndpoint( + id: 'overpass-deflock', + name: 'Deflock Node Source', + url: 'https://overpass.deflock.org/api/interpreter', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'overpass-public', + name: 'Public Overpass', + url: 'https://overpass-api.de/api/interpreter', + isBuiltIn: true, + ), + ]; +} diff --git a/lib/models/service_registry_entry.dart b/lib/models/service_registry_entry.dart new file mode 100644 index 00000000..db671b75 --- /dev/null +++ b/lib/models/service_registry_entry.dart @@ -0,0 +1,22 @@ +/// Shared interface for entries managed by a [ServiceRegistry]. +/// +/// Both [ServiceEndpoint] and (in a future PR) [TileType] implement this, +/// enabling generic list management, persistence, and UI components. +abstract interface class ServiceRegistryEntry { + /// Unique identifier for this entry. + String get id; + + /// Human-readable display name. + String get name; + + /// Whether this entry is active. Disabled entries are skipped by + /// the resilience engine and hidden from primary selection UI. + bool get enabled; + + /// Whether this entry was provided by the app (built-in default). + /// Built-in entries cannot be deleted in production builds. + bool get isBuiltIn; + + /// Serialize to JSON for SharedPreferences persistence. + Map toJson(); +} diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index b5630a44..ffc78a30 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'settings/sections/api_endpoints_section.dart'; import 'settings/sections/max_nodes_section.dart'; import 'settings/sections/proximity_alerts_section.dart'; import 'settings/sections/suspected_locations_section.dart'; @@ -35,6 +36,8 @@ class AdvancedSettingsScreen extends StatelessWidget { // NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled // Divider(), TileProviderSection(), + Divider(), + ApiEndpointsSection(), ], ), ), diff --git a/lib/screens/settings/sections/api_endpoints_section.dart b/lib/screens/settings/sections/api_endpoints_section.dart new file mode 100644 index 00000000..da4cc510 --- /dev/null +++ b/lib/screens/settings/sections/api_endpoints_section.dart @@ -0,0 +1,280 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../app_state.dart'; +import '../../../models/service_endpoint.dart'; +import '../../../state/service_registry.dart'; +import '../../../services/localization_service.dart'; + +class ApiEndpointsSection extends StatelessWidget { + const ApiEndpointsSection({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => Consumer( + builder: (context, appState, _) { + final loc = LocalizationService.instance; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.t('settings.apiEndpoints'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + loc.t('settings.apiEndpointsDescription'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointRouting'), + registry: appState.routingRegistry, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointNodeSource'), + registry: appState.overpassRegistry, + ), + ], + ); + }, + ), + ); + } +} + +class _EndpointRegistryList extends StatelessWidget { + final String label; + final ServiceRegistry registry; + + const _EndpointRegistryList({ + required this.label, + required this.registry, + }); + + bool _isValidHttpsUrl(String url) { + if (url.isEmpty) return false; + final uri = Uri.tryParse(url); + return uri != null && uri.scheme == 'https' && uri.host.isNotEmpty; + } + + @override + Widget build(BuildContext context) { + final loc = LocalizationService.instance; + final entries = registry.entries; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + )), + const SizedBox(height: 8), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: entries.length, + onReorder: (oldIndex, newIndex) { + registry.reorder(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final endpoint = entries[index]; + return _EndpointTile( + key: ValueKey(endpoint.id), + endpoint: endpoint, + index: index, + registry: registry, + canDelete: !endpoint.isBuiltIn || kDebugMode, + onToggle: (enabled) { + // Prevent disabling the last enabled endpoint + if (!enabled && registry.enabledEntries.length <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(loc.t('settings.noEnabledEndpoints'))), + ); + return; + } + registry.addOrUpdate(endpoint.copyWith(enabled: enabled)); + }, + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), + label: Text(loc.t('settings.addEndpoint')), + onPressed: () => _showAddDialog(context), + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.restore, size: 18), + label: Text(loc.t('settings.resetToDefaults')), + onPressed: () => _showResetDialog(context), + ), + ], + ), + ], + ); + } + + void _showAddDialog(BuildContext context) { + final loc = LocalizationService.instance; + final nameController = TextEditingController(); + final urlController = TextEditingController(); + String? urlError; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(loc.t('settings.addEndpoint')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointName'), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointUrl'), + border: const OutlineInputBorder(), + errorText: urlError, + hintText: 'https://', + ), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + final url = urlController.text.trim(); + final name = nameController.text.trim(); + if (!_isValidHttpsUrl(url)) { + setState(() => urlError = loc.t('settings.invalidUrl')); + return; + } + if (name.isEmpty) return; + final id = 'custom-${DateTime.now().millisecondsSinceEpoch}'; + registry.addOrUpdate(ServiceEndpoint( + id: id, + name: name, + url: url, + )); + Navigator.pop(context); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ), + ); + } + + void _showResetDialog(BuildContext context) { + final loc = LocalizationService.instance; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(loc.t('settings.resetToDefaults')), + content: Text(loc.t('settings.confirmResetEndpoints')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + registry.resetToDefaults(); + Navigator.pop(dialogContext); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ); + } +} + +class _EndpointTile extends StatelessWidget { + final ServiceEndpoint endpoint; + final int index; + final ServiceRegistry registry; + final bool canDelete; + final void Function(bool enabled) onToggle; + + const _EndpointTile({ + super.key, + required this.endpoint, + required this.index, + required this.registry, + required this.canDelete, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle, size: 20), + ), + title: Text( + endpoint.name, + style: theme.textTheme.bodyMedium?.copyWith( + color: endpoint.enabled ? null : theme.disabledColor, + ), + ), + subtitle: Text( + endpoint.url, + style: theme.textTheme.bodySmall?.copyWith( + color: endpoint.enabled ? theme.hintColor : theme.disabledColor, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: endpoint.enabled, + onChanged: onToggle, + ), + if (canDelete) + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () { + try { + registry.delete(endpoint.id); + } on StateError catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 6f33c5bb..0b882917 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -6,6 +6,8 @@ import 'package:flutter_map/flutter_map.dart'; import '../models/node_profile.dart'; import '../models/osm_node.dart'; +import '../models/service_endpoint.dart'; +import '../app_state.dart'; import '../dev_config.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -21,15 +23,24 @@ class OverpassService { ); final http.Client _client; - /// Optional override endpoint. When null, uses defaultEndpoint (or settings override). - final String? _endpointOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - OverpassService({http.Client? client, String? endpoint}) + OverpassService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _endpointOverride = endpoint; + _endpointsOverride = endpoints; - /// Resolve the primary endpoint: constructor override or default. - String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledOverpassEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized (e.g., in tests) + } + return DefaultServiceEndpoints.overpass(); + } /// Fetch surveillance nodes from Overpass API with retry and fallback. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. @@ -41,9 +52,6 @@ class OverpassService { if (profiles.isEmpty) return []; final query = _buildQuery(bounds, profiles); - // Snapshot the endpoint once so fallback decision is consistent - final endpoint = _primaryEndpoint; - final canFallback = endpoint == defaultEndpoint; final effectivePolicy = maxRetries != null ? ResiliencePolicy( @@ -52,12 +60,11 @@ class OverpassService { ) : _policy; - return executeWithFallback>( - primaryUrl: endpoint, - fallbackUrl: canFallback ? fallbackEndpoint : null, + return executeWithEndpointList>( + endpoints: _endpoints, execute: (url) => _attemptFetch(url, query), classifyError: _classifyError, - policy: effectivePolicy, + defaultPolicy: effectivePolicy, ); } diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index affd4ce1..c8ae5a7c 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; +import '../models/service_endpoint.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -34,17 +35,26 @@ class RoutingService { ); final http.Client _client; - /// Optional override URL. When null, uses defaultUrl (or settings override). - final String? _baseUrlOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - RoutingService({http.Client? client, String? baseUrl}) + RoutingService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _baseUrlOverride = baseUrl; + _endpointsOverride = endpoints; void close() => _client.close(); - /// Resolve the primary URL to use: constructor override or default. - String get _primaryUrl => _baseUrlOverride ?? defaultUrl; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledRoutingEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized yet (e.g., in tests) + } + return DefaultServiceEndpoints.routing(); + } // Calculate route between two points Future calculateRoute({ @@ -81,16 +91,11 @@ class RoutingService { 'show_exclusion_zone': false, }; - // Snapshot the URL once so fallback decision is consistent - final primaryUrl = _primaryUrl; - final canFallback = primaryUrl == defaultUrl; - - return executeWithFallback( - primaryUrl: primaryUrl, - fallbackUrl: canFallback ? fallbackUrl : null, + return executeWithEndpointList( + endpoints: _endpoints, execute: (url) => _postRoute(url, params), classifyError: _classifyError, - policy: _policy, + defaultPolicy: _policy, ); } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 63324641..b28c86ac 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/service_endpoint.dart'; /// Identifies the type of external service being accessed. /// Used by [ServicePolicyResolver] to determine the correct compliance policy. @@ -298,24 +299,71 @@ enum ErrorDisposition { class ResiliencePolicy { final int maxRetries; final Duration httpTimeout; - final Duration _retryBackoffBase; - final int _retryBackoffMaxMs; + final Duration retryBackoffBase; + final int retryBackoffMaxMs; const ResiliencePolicy({ this.maxRetries = 1, this.httpTimeout = const Duration(seconds: 30), - Duration retryBackoffBase = const Duration(milliseconds: 200), - int retryBackoffMaxMs = 5000, - }) : _retryBackoffBase = retryBackoffBase, - _retryBackoffMaxMs = retryBackoffMaxMs; + this.retryBackoffBase = const Duration(milliseconds: 200), + this.retryBackoffMaxMs = 5000, + }); Duration retryDelay(int attempt) { - final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt)) - .clamp(0, _retryBackoffMaxMs); + final ms = (retryBackoffBase.inMilliseconds * (1 << attempt)) + .clamp(0, retryBackoffMaxMs); return Duration(milliseconds: ms); } } +/// Execute a request against an ordered list of endpoints with retry and fallback. +/// +/// Tries each enabled endpoint in list order. For each endpoint: +/// 1. Applies per-endpoint policy overrides (maxRetries, timeout) over [defaultPolicy] +/// 2. Retries on [ErrorDisposition.retry] errors up to the effective maxRetries +/// 3. On [ErrorDisposition.fallback], skips remaining retries and moves to next endpoint +/// 4. On [ErrorDisposition.abort], rethrows immediately (no further endpoints tried) +/// +/// Throws [StateError] if no endpoints are enabled. +/// Rethrows the last error if all endpoints are exhausted. +Future executeWithEndpointList({ + required List endpoints, + required Future Function(String url) execute, + required ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy defaultPolicy = const ResiliencePolicy(), +}) async { + final enabled = endpoints.where((e) => e.enabled).toList(growable: false); + if (enabled.isEmpty) { + throw StateError('No enabled endpoints configured'); + } + + Object? lastError; + for (final endpoint in enabled) { + final effectivePolicy = ResiliencePolicy( + maxRetries: endpoint.maxRetries ?? defaultPolicy.maxRetries, + httpTimeout: endpoint.timeoutSeconds != null + ? Duration(seconds: endpoint.timeoutSeconds!) + : defaultPolicy.httpTimeout, + retryBackoffBase: defaultPolicy.retryBackoffBase, + retryBackoffMaxMs: defaultPolicy.retryBackoffMaxMs, + ); + try { + return await _executeWithRetries( + endpoint.url, + execute, + classifyError, + effectivePolicy, + ); + } catch (e) { + final disposition = classifyError(e); + if (disposition == ErrorDisposition.abort) rethrow; + lastError = e; + debugPrint('[Resilience] Endpoint ${endpoint.name} failed ($e), trying next'); + } + } + throw lastError!; // All endpoints exhausted +} + /// Execute a request with retry and fallback logic. /// /// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. @@ -325,6 +373,7 @@ class ResiliencePolicy { /// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted /// 3. If [fallbackUrl] is non-null and the error is fallback-worthy, /// repeats the retry loop against the fallback. +@Deprecated('Use executeWithEndpointList instead') Future executeWithFallback({ required String primaryUrl, required String? fallbackUrl, diff --git a/lib/state/service_registry.dart b/lib/state/service_registry.dart new file mode 100644 index 00000000..e2d74e26 --- /dev/null +++ b/lib/state/service_registry.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/service_registry_entry.dart'; + +/// Generic list manager for [ServiceRegistryEntry] subtypes. +/// +/// Handles persistence (JSON in SharedPreferences), CRUD operations, +/// reordering, default management, and change notification. +class ServiceRegistry { + /// SharedPreferences key for this registry's data. + final String prefsKey; + + /// Deserialize a JSON map to a registry entry. + final T Function(Map) fromJson; + + /// Factory for creating the default entries (built-in endpoints/tiles). + final List Function() createDefaults; + + /// Called after every mutation to notify the owning state object. + final VoidCallback onChanged; + + List _entries = []; + SharedPreferences? _prefs; + + ServiceRegistry({ + required this.prefsKey, + required this.fromJson, + required this.createDefaults, + required this.onChanged, + }); + + /// Unmodifiable view of all entries in priority order. + List get entries => List.unmodifiable(_entries); + + /// Only entries with [enabled] == true, in priority order. + List get enabledEntries => + _entries.where((e) => e.enabled).toList(growable: false); + + /// Load from SharedPreferences; initialize with defaults if absent or corrupt. + Future load(SharedPreferences prefs) async { + _prefs = prefs; + if (prefs.containsKey(prefsKey)) { + try { + final json = jsonDecode(prefs.getString(prefsKey)!) as List; + _entries = json + .map((j) => fromJson(j as Map)) + .toList(); + await _addMissingDefaults(); + return; + } catch (e) { + debugPrint('[ServiceRegistry] Error loading $prefsKey: $e'); + } + } + _entries = List.of(createDefaults()); + await _save(); + } + + /// Add a new entry or update an existing one (matched by id). + /// New entries are appended; existing entries are replaced in-place. + Future addOrUpdate(T entry) async { + final index = _entries.indexWhere((e) => e.id == entry.id); + if (index >= 0) { + _entries[index] = entry; + } else { + _entries.add(entry); + } + await _save(); + onChanged(); + } + + /// Remove an entry by id. + /// Throws [StateError] if this would empty the list. + /// Throws [UnsupportedError] if the entry is built-in and we're in release mode. + Future delete(String id) async { + final index = _entries.indexWhere((e) => e.id == id); + if (index < 0) return; + if (_entries.length <= 1) { + throw StateError('Cannot delete the last entry'); + } + if (_entries[index].isBuiltIn && !kDebugMode) { + throw UnsupportedError('Cannot delete built-in entries in release mode'); + } + _entries.removeAt(index); + await _save(); + onChanged(); + } + + /// Move entry from [oldIndex] to [newIndex]. + Future reorder(int oldIndex, int newIndex) async { + if (oldIndex == newIndex) return; + // ReorderableListView convention: if moving down, newIndex is +1 + final adjustedNew = newIndex > oldIndex ? newIndex - 1 : newIndex; + final entry = _entries.removeAt(oldIndex); + _entries.insert(adjustedNew, entry); + await _save(); + onChanged(); + } + + /// Replace all entries with defaults. + Future resetToDefaults() async { + _entries = List.of(createDefaults()); + await _save(); + onChanged(); + } + + /// Find an entry by id, or null if not found. + T? findById(String id) { + for (final e in _entries) { + if (e.id == id) return e; + } + return null; + } + + /// Add any default entries that are missing from the current list. + Future _addMissingDefaults() async { + final existingIds = _entries.map((e) => e.id).toSet(); + final defaults = createDefaults(); + var changed = false; + for (final d in defaults) { + if (!existingIds.contains(d.id)) { + _entries.add(d); + changed = true; + } + } + if (changed) await _save(); + } + + Future _save() async { + final json = jsonEncode(_entries.map((e) => e.toJson()).toList()); + await _prefs!.setString(prefsKey, json); + } +} diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 7fbc6d09..5b726850 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:collection/collection.dart'; +import '../models/service_endpoint.dart'; import '../models/tile_provider.dart'; import '../dev_config.dart'; import '../keys.dart'; +import 'service_registry.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -38,6 +40,10 @@ class SettingsState extends ChangeNotifier { static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; static const String _distanceUnitPrefsKey = 'distance_unit'; + static const String _routingEndpointPrefsKey = 'routing_endpoint'; + static const String _overpassEndpointPrefsKey = 'overpass_endpoint'; + static const String _routingEndpointsPrefsKey = 'routing_endpoints'; + static const String _overpassEndpointsPrefsKey = 'overpass_endpoints'; bool _offlineMode = false; bool _pauseQueueProcessing = false; @@ -54,6 +60,20 @@ class SettingsState extends ChangeNotifier { int _navigationAvoidanceDistance = 250; // meters DistanceUnit _distanceUnit = DistanceUnit.metric; + late final ServiceRegistry _routingRegistry = ServiceRegistry( + prefsKey: _routingEndpointsPrefsKey, + fromJson: ServiceEndpoint.fromJson, + createDefaults: DefaultServiceEndpoints.routing, + onChanged: notifyListeners, + ); + + late final ServiceRegistry _overpassRegistry = ServiceRegistry( + prefsKey: _overpassEndpointsPrefsKey, + fromJson: ServiceEndpoint.fromJson, + createDefaults: DefaultServiceEndpoints.overpass, + onChanged: notifyListeners, + ); + // Getters bool get offlineMode => _offlineMode; bool get pauseQueueProcessing => _pauseQueueProcessing; @@ -68,7 +88,25 @@ class SettingsState extends ChangeNotifier { String get selectedTileTypeId => _selectedTileTypeId; int get navigationAvoidanceDistance => _navigationAvoidanceDistance; DistanceUnit get distanceUnit => _distanceUnit; - + + /// Ordered list of routing endpoints (priority order). + List get routingEndpoints => _routingRegistry.entries; + + /// Only enabled routing endpoints. + List get enabledRoutingEndpoints => _routingRegistry.enabledEntries; + + /// Ordered list of Overpass endpoints (priority order). + List get overpassEndpoints => _overpassRegistry.entries; + + /// Only enabled Overpass endpoints. + List get enabledOverpassEndpoints => _overpassRegistry.enabledEntries; + + /// The routing registry for direct UI manipulation. + ServiceRegistry get routingRegistry => _routingRegistry; + + /// The Overpass registry for direct UI manipulation. + ServiceRegistry get overpassRegistry => _overpassRegistry; + /// Get the currently selected tile type TileType? get selectedTileType { for (final provider in _tileProviders) { @@ -167,6 +205,22 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } + // Migrate old single-string endpoints to new registry format + await _migrateOldEndpoint( + prefs: prefs, + oldKey: _routingEndpointPrefsKey, + registry: _routingRegistry, + ); + await _migrateOldEndpoint( + prefs: prefs, + oldKey: _overpassEndpointPrefsKey, + registry: _overpassRegistry, + ); + + // Load endpoint registries + await _routingRegistry.load(prefs); + await _overpassRegistry.load(prefs); + // Load tile providers (default to built-in providers if none saved) await _loadTileProviders(prefs); @@ -405,4 +459,37 @@ class SettingsState extends ChangeNotifier { } } + /// Migrate old single-string endpoint to new registry list format. + Future _migrateOldEndpoint({ + required SharedPreferences prefs, + required String oldKey, + required ServiceRegistry registry, + }) async { + // Skip if new format already exists or old format doesn't + if (prefs.containsKey(registry.prefsKey) || !prefs.containsKey(oldKey)) return; + + final oldUrl = prefs.getString(oldKey); + if (oldUrl != null && oldUrl.isNotEmpty) { + // Create a list: custom endpoint first (highest priority), then defaults + final defaults = registry.createDefaults(); + // Check if custom URL matches any default + final isDefault = defaults.any((d) => d.url == oldUrl); + if (!isDefault) { + final customEntry = ServiceEndpoint( + id: 'custom-${oldKey.replaceAll('_', '-')}', + name: 'Custom', + url: oldUrl, + ); + final migrated = [customEntry, ...defaults]; + await prefs.setString( + registry.prefsKey, + jsonEncode(migrated.map((e) => e.toJson()).toList()), + ); + } + } + // Clean up old key + await prefs.remove(oldKey); + } + } + diff --git a/pubspec.lock b/pubspec.lock index b61873af..b3ea42f0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -564,18 +564,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -929,10 +929,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: diff --git a/test/models/service_endpoint_test.dart b/test/models/service_endpoint_test.dart new file mode 100644 index 00000000..92d68357 --- /dev/null +++ b/test/models/service_endpoint_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; + +void main() { + group('ServiceEndpoint', () { + test('toJson/fromJson round-trip preserves all fields', () { + const endpoint = ServiceEndpoint( + id: 'test', name: 'Test', url: 'https://example.com', + enabled: false, isBuiltIn: true, maxRetries: 5, timeoutSeconds: 10, + ); + final restored = ServiceEndpoint.fromJson(endpoint.toJson()); + expect(restored.id, endpoint.id); + expect(restored.name, endpoint.name); + expect(restored.url, endpoint.url); + expect(restored.enabled, endpoint.enabled); + expect(restored.isBuiltIn, endpoint.isBuiltIn); + expect(restored.maxRetries, endpoint.maxRetries); + expect(restored.timeoutSeconds, endpoint.timeoutSeconds); + }); + + test('fromJson uses defaults for missing optional fields', () { + final endpoint = ServiceEndpoint.fromJson({ + 'id': 'x', 'name': 'X', 'url': 'https://x.com', + }); + expect(endpoint.enabled, isTrue); + expect(endpoint.isBuiltIn, isFalse); + expect(endpoint.maxRetries, isNull); + expect(endpoint.timeoutSeconds, isNull); + }); + + test('copyWith replaces specified fields only', () { + const original = ServiceEndpoint( + id: 'a', name: 'A', url: 'https://a.com', + ); + final modified = original.copyWith(name: 'B', enabled: false); + expect(modified.id, 'a'); + expect(modified.name, 'B'); + expect(modified.url, 'https://a.com'); + expect(modified.enabled, isFalse); + }); + + test('equality is by id', () { + const a = ServiceEndpoint(id: 'x', name: 'A', url: 'https://a.com'); + const b = ServiceEndpoint(id: 'x', name: 'B', url: 'https://b.com'); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('different ids are not equal', () { + const a = ServiceEndpoint(id: 'x', name: 'A', url: 'https://a.com'); + const b = ServiceEndpoint(id: 'y', name: 'A', url: 'https://a.com'); + expect(a, isNot(equals(b))); + }); + }); + + group('DefaultServiceEndpoints', () { + test('routing returns 2 built-in endpoints', () { + final endpoints = DefaultServiceEndpoints.routing(); + expect(endpoints, hasLength(2)); + expect(endpoints.every((e) => e.isBuiltIn), isTrue); + expect(endpoints.every((e) => e.enabled), isTrue); + }); + + test('overpass returns 2 built-in endpoints', () { + final endpoints = DefaultServiceEndpoints.overpass(); + expect(endpoints, hasLength(2)); + expect(endpoints.every((e) => e.isBuiltIn), isTrue); + expect(endpoints.every((e) => e.enabled), isTrue); + }); + + test('routing endpoints have expected URLs', () { + final endpoints = DefaultServiceEndpoints.routing(); + expect(endpoints[0].url, contains('dontgetflocked.com')); + expect(endpoints[1].url, contains('alprwatch.org')); + }); + + test('overpass endpoints have expected URLs', () { + final endpoints = DefaultServiceEndpoints.overpass(); + expect(endpoints[0].url, contains('overpass.deflock.org')); + expect(endpoints[1].url, contains('overpass-api.de')); + }); + }); +} diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index 15f278ce..16298d90 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/overpass_service.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -36,8 +37,11 @@ void main() { setUp(() { mockClient = MockHttpClient(); - // Use explicit endpoint to avoid AppState dependency in tests - service = OverpassService(client: mockClient, endpoint: OverpassService.defaultEndpoint); + // Use explicit endpoints to avoid AppState dependency in tests + service = OverpassService( + client: mockClient, + endpoints: DefaultServiceEndpoints.overpass(), + ); }); /// Helper: stub a successful Overpass response with the given elements. @@ -420,10 +424,12 @@ void main() { .called(2); }); - test('does NOT fallback when using custom endpoint', () async { + test('single custom endpoint does not fallback', () async { final customService = OverpassService( client: mockClient, - endpoint: 'https://custom.example.com/api/interpreter', + endpoints: const [ + ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.example.com/api/interpreter'), + ], ); when(() => mockClient.post(any(), body: any(named: 'body'))) diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart index 6b9bcc46..76da9bbb 100644 --- a/test/services/routing_service_test.dart +++ b/test/services/routing_service_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:deflockapp/app_state.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/routing_service.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -34,8 +35,11 @@ void main() { mockAppState = MockAppState(); AppState.instance = mockAppState; - // Use a fixed baseUrl so tests don't try to resolve AppState settings - service = RoutingService(client: mockClient, baseUrl: RoutingService.defaultUrl); + // Use fixed endpoints so tests don't try to resolve AppState settings + service = RoutingService( + client: mockClient, + endpoints: DefaultServiceEndpoints.routing(), + ); }); tearDown(() { @@ -456,10 +460,12 @@ void main() { )).called(4); }); - test('does NOT fallback when using custom baseUrl', () async { + test('single custom endpoint does not fallback', () async { final customService = RoutingService( client: mockClient, - baseUrl: 'https://custom.example.com/route', + endpoints: const [ + ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.example.com/route'), + ], ); when(() => mockAppState.enabledProfiles).thenReturn([]); @@ -477,7 +483,7 @@ void main() { throwsA(isA()), ); - // 2 attempts (1 + 1 retry), no fallback with custom URL + // 2 attempts (1 + 1 retry), no fallback with single endpoint verify(() => mockClient.post( any(), headers: any(named: 'headers'), diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index f1cc0a5a..4b6a2d41 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -1,6 +1,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/service_policy.dart'; void main() { @@ -447,6 +448,212 @@ void main() { }); }); + group('executeWithEndpointList', () { + const defaultPolicy = ResiliencePolicy( + maxRetries: 1, + retryBackoffBase: Duration.zero, + ); + + test('throws StateError when no endpoints enabled', () async { + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', enabled: false), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ), + throwsA(isA()), + ); + }); + + test('succeeds with single enabled endpoint', () async { + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ); + expect(result, 'ok'); + }); + + test('tries next endpoint when retry exhausted', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://a.com') throw Exception('fail'); + return Future.value('ok from b'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: defaultPolicy, + ); + + expect(result, 'ok from b'); + // endpoint A: 1 attempt + 1 retry = 2 calls, then endpoint B: 1 call + expect(urlsSeen.where((u) => u == 'https://a.com').length, 2); + expect(urlsSeen.where((u) => u == 'https://b.com').length, 1); + }); + + test('fallback disposition skips to next immediately', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://a.com') throw Exception('rate limited'); + return Future.value('ok from b'); + }, + classifyError: (_) => ErrorDisposition.fallback, + defaultPolicy: defaultPolicy, + ); + + expect(result, 'ok from b'); + // Fallback: only 1 call to A (no retries), then 1 call to B + expect(urlsSeen.where((u) => u == 'https://a.com').length, 1); + expect(urlsSeen.where((u) => u == 'https://b.com').length, 1); + }); + + test('abort stops entire chain', () async { + final urlsSeen = []; + + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + throw Exception('validation error'); + }, + classifyError: (_) => ErrorDisposition.abort, + defaultPolicy: defaultPolicy, + ), + throwsA(isA()), + ); + + // Only A called once, B never called + expect(urlsSeen, ['https://a.com']); + }); + + test('per-endpoint maxRetries override', () async { + int callCount = 0; + + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', maxRetries: 0), + ], + execute: (url) { + callCount++; + throw Exception('fail'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 5, + retryBackoffBase: Duration.zero, + ), + ), + throwsA(isA()), + ); + + // maxRetries=0 means only 1 attempt (no retries) + expect(callCount, 1); + }); + + test('per-endpoint timeout override is used in effective policy', () async { + // We can't easily test the actual timeout behavior, but we can verify + // the endpoint is used successfully with the override + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', timeoutSeconds: 1), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ); + expect(result, 'ok'); + }); + + test('disabled endpoints skipped', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'disabled', name: 'Disabled', url: 'https://disabled.com', enabled: false), + ServiceEndpoint(id: 'enabled', name: 'Enabled', url: 'https://enabled.com'), + ], + execute: (url) { + urlsSeen.add(url); + return Future.value('ok'); + }, + classifyError: (_) => ErrorDisposition.retry, + ); + + expect(result, 'ok'); + expect(urlsSeen, ['https://enabled.com']); + }); + + test('all endpoints fail rethrows last error', () async { + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + if (url == 'https://b.com') throw Exception('b failed'); + throw Exception('a failed'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('b failed'), + )), + ); + }); + + test('3 endpoints, first 2 fail, third succeeds', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ServiceEndpoint(id: 'c', name: 'C', url: 'https://c.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://c.com') return Future.value('ok from c'); + throw Exception('fail'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ); + + expect(result, 'ok from c'); + expect(urlsSeen, ['https://a.com', 'https://b.com', 'https://c.com']); + }); + }); + + // ignore: deprecated_member_use_from_same_package group('executeWithFallback', () { const policy = ResiliencePolicy( maxRetries: 2, diff --git a/test/state/service_registry_test.dart b/test/state/service_registry_test.dart new file mode 100644 index 00000000..6d14c3c4 --- /dev/null +++ b/test/state/service_registry_test.dart @@ -0,0 +1,362 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:deflockapp/models/service_registry_entry.dart'; +import 'package:deflockapp/state/service_registry.dart'; + +/// Simple test entry implementing ServiceRegistryEntry. +class TestEntry implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + @override + final bool enabled; + @override + final bool isBuiltIn; + + const TestEntry({ + required this.id, + required this.name, + this.enabled = true, + this.isBuiltIn = false, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + }; + + static TestEntry fromJson(Map json) => TestEntry( + id: json['id'] as String, + name: json['name'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + ); + + TestEntry copyWith({bool? enabled}) => TestEntry( + id: id, + name: name, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn, + ); +} + +List _createDefaults() => const [ + TestEntry(id: 'default-1', name: 'Default One', isBuiltIn: true), + TestEntry(id: 'default-2', name: 'Default Two', isBuiltIn: true), +]; + +void main() { + late ServiceRegistry registry; + late int changeCount; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + changeCount = 0; + registry = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () => changeCount++, + ); + }); + + group('load', () { + test('fresh install creates and persists defaults', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + expect(registry.entries[1].id, 'default-2'); + + // Verify persisted + expect(prefs.containsKey('test_entries'), isTrue); + }); + + test('existing valid JSON loads entries', () async { + final existing = [ + const TestEntry(id: 'custom', name: 'Custom'), + const TestEntry(id: 'default-1', name: 'D1', isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', isBuiltIn: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(3)); + expect(registry.entries[0].id, 'custom'); + }); + + test('corrupted JSON falls back to defaults', () async { + SharedPreferences.setMockInitialValues({ + 'test_entries': 'not valid json!!!', + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + }); + + test('adds missing built-in entries to existing list', () async { + final existing = [ + const TestEntry(id: 'custom', name: 'Custom'), + const TestEntry(id: 'default-1', name: 'D1', isBuiltIn: true), + // default-2 is missing + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(3)); + expect(registry.entries[2].id, 'default-2'); + }); + }); + + group('addOrUpdate', () { + test('new entry appended to end', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'new-one', name: 'New One'), + ); + + expect(registry.entries, hasLength(3)); + expect(registry.entries.last.id, 'new-one'); + expect(changeCount, 1); + }); + + test('existing entry replaced in-place preserving position', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'default-1', name: 'Updated Name', isBuiltIn: true), + ); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].name, 'Updated Name'); + expect(changeCount, 1); + }); + + test('persists changes', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'new-one', name: 'New'), + ); + + // Re-load from prefs to verify persistence + final registry2 = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () {}, + ); + await registry2.load(prefs); + expect(registry2.entries, hasLength(3)); + expect(registry2.entries.last.id, 'new-one'); + }); + }); + + group('delete', () { + test('removes entry and persists', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + // Add a custom entry first so we have 3 + await registry.addOrUpdate( + const TestEntry(id: 'custom', name: 'Custom'), + ); + changeCount = 0; + + await registry.delete('custom'); + + expect(registry.entries, hasLength(2)); + expect(registry.findById('custom'), isNull); + expect(changeCount, 1); + }); + + test('throws StateError on last entry', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + // Remove one default so only one remains + await registry.delete('default-2'); + + await expectLater( + () => registry.delete('default-1'), + throwsA(isA()), + ); + }); + + test('no-op for nonexistent id', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.delete('nonexistent'); + expect(registry.entries, hasLength(2)); + expect(changeCount, 0); + }); + }); + + group('reorder', () { + test('moves entry from first to last', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'third', name: 'Third'), + ); + changeCount = 0; + + await registry.reorder(0, 3); // Move index 0 to after index 2 + + expect(registry.entries[0].id, 'default-2'); + expect(registry.entries[1].id, 'third'); + expect(registry.entries[2].id, 'default-1'); + expect(changeCount, 1); + }); + + test('same index is no-op', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.reorder(0, 0); + + expect(changeCount, 0); + }); + + test('persists new order', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.reorder(0, 2); + + // Re-load + final registry2 = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () {}, + ); + await registry2.load(prefs); + expect(registry2.entries[0].id, 'default-2'); + expect(registry2.entries[1].id, 'default-1'); + }); + }); + + group('resetToDefaults', () { + test('replaces all entries with defaults', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'custom', name: 'Custom'), + ); + changeCount = 0; + + await registry.resetToDefaults(); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + expect(changeCount, 1); + }); + + test('custom entries removed', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'custom-1', name: 'C1'), + ); + await registry.addOrUpdate( + const TestEntry(id: 'custom-2', name: 'C2'), + ); + + await registry.resetToDefaults(); + + expect(registry.findById('custom-1'), isNull); + expect(registry.findById('custom-2'), isNull); + }); + }); + + group('enabledEntries', () { + test('filters disabled entries', () async { + // Include the defaults (which load will check for) plus custom entries + final existing = [ + const TestEntry(id: 'default-1', name: 'D1', enabled: true, isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', enabled: false, isBuiltIn: true), + const TestEntry(id: 'c', name: 'C', enabled: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + final enabled = registry.enabledEntries; + expect(enabled, hasLength(2)); + expect(enabled[0].id, 'default-1'); + expect(enabled[1].id, 'c'); + }); + + test('returns empty list when all disabled', () async { + final existing = [ + const TestEntry(id: 'default-1', name: 'D1', enabled: false, isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', enabled: false, isBuiltIn: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.enabledEntries, isEmpty); + }); + }); + + group('findById', () { + test('returns entry when found', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.findById('default-1')?.name, 'Default One'); + }); + + test('returns null when not found', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.findById('nonexistent'), isNull); + }); + }); + + group('entries list immutability', () { + test('entries returns unmodifiable list', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect( + () => registry.entries.add(const TestEntry(id: 'x', name: 'X')), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/state/settings_state_test.dart b/test/state/settings_state_test.dart index 8e56a242..a5535064 100644 --- a/test/state/settings_state_test.dart +++ b/test/state/settings_state_test.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/state/settings_state.dart'; import 'package:deflockapp/keys.dart'; @@ -70,4 +72,157 @@ void main() { expect(state.uploadMode, UploadMode.simulate); }); }); + + group('Endpoint registry', () { + test('fresh install loads defaults for both registries', () async { + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + expect(state.routingEndpoints[0].id, 'routing-deflock'); + expect(state.overpassEndpoints, hasLength(2)); + expect(state.overpassEndpoints[0].id, 'overpass-deflock'); + }); + + test('registries return unmodifiable lists', () async { + final state = SettingsState(); + await state.init(); + + expect( + () => state.routingEndpoints.add( + const ServiceEndpoint(id: 'x', name: 'X', url: 'https://x.com'), + ), + throwsA(isA()), + ); + }); + + test('registry mutations fire notifications', () async { + final state = SettingsState(); + await state.init(); + + int notifyCount = 0; + state.addListener(() => notifyCount++); + + await state.routingRegistry.addOrUpdate( + const ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.com'), + ); + expect(notifyCount, 1); + }); + + test('enabledRoutingEndpoints filters disabled entries', () async { + final state = SettingsState(); + await state.init(); + + // Disable the first routing endpoint + final first = state.routingEndpoints[0]; + await state.routingRegistry.addOrUpdate( + first.copyWith(enabled: false), + ); + + expect(state.enabledRoutingEndpoints, hasLength(1)); + expect(state.enabledRoutingEndpoints[0].id, 'routing-alprwatch'); + }); + }); + + group('Endpoint migration', () { + test('old routing_endpoint string migrates to registry list', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://custom.example.com/route', + }); + + final state = SettingsState(); + await state.init(); + + // Should have 3 endpoints: custom first, then 2 defaults + expect(state.routingEndpoints, hasLength(3)); + expect(state.routingEndpoints[0].url, 'https://custom.example.com/route'); + expect(state.routingEndpoints[0].name, 'Custom'); + expect(state.routingEndpoints[1].id, 'routing-deflock'); + + // Old key should be removed + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('routing_endpoint'), isFalse); + }); + + test('old overpass_endpoint string migrates to registry list', () async { + SharedPreferences.setMockInitialValues({ + 'overpass_endpoint': 'https://custom-overpass.example.com/api/interpreter', + }); + + final state = SettingsState(); + await state.init(); + + expect(state.overpassEndpoints, hasLength(3)); + expect(state.overpassEndpoints[0].url, 'https://custom-overpass.example.com/api/interpreter'); + }); + + test('old key matching a default URL does not create duplicate', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://api.dontgetflocked.com/api/v1/deflock/directions', + }); + + final state = SettingsState(); + await state.init(); + + // Should just have the 2 defaults, no duplicate custom entry + expect(state.routingEndpoints, hasLength(2)); + }); + + test('no old key with no new key creates defaults', () async { + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + expect(state.overpassEndpoints, hasLength(2)); + }); + + test('new format takes precedence over old format', () async { + final existing = [ + const ServiceEndpoint(id: 'custom', name: 'Existing', url: 'https://existing.com'), + ]; + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://old.example.com/route', + 'routing_endpoints': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final state = SettingsState(); + await state.init(); + + // New format should take precedence; old key is NOT migrated when new exists + // The registry should load existing + add missing defaults + expect(state.routingEndpoints[0].url, 'https://existing.com'); + }); + + test('migration is idempotent', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://custom.example.com/route', + }); + + final state1 = SettingsState(); + await state1.init(); + final count1 = state1.routingEndpoints.length; + + // Init again (simulating app restart) + final state2 = SettingsState(); + await state2.init(); + + expect(state2.routingEndpoints.length, count1); + }); + + test('empty old endpoint string does not create custom entry', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': '', + }); + + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + + // Old key should be cleaned up + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('routing_endpoint'), isFalse); + }); + }); } + From 3e51ad098510ddd27a3c6fe9154cbd29c688f3fe Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Thu, 12 Mar 2026 00:07:42 -0600 Subject: [PATCH 6/6] Add resilience metrics and improved logging for retry/fallback Add ResilienceMetrics singleton to track request counts, retry/fallback invocations, error types, and latency. Instrument executeWithEndpointList and _executeWithRetries with structured debug logging that includes service name, attempt count, error type, and disposition. Remove deprecated executeWithFallback (no remaining callers). Introduce _ClassifiedError wrapper so callers receive the disposition from _executeWithRetries without re-classifying errors. Co-Authored-By: Claude Opus 4.6 --- lib/services/overpass_service.dart | 3 +- lib/services/routing_service.dart | 1 + lib/services/service_policy.dart | 182 ++++++++++++++---- test/services/service_policy_test.dart | 251 +++++++++++-------------- 4 files changed, 255 insertions(+), 182 deletions(-) diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 0b882917..6d05b744 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -65,10 +65,11 @@ class OverpassService { execute: (url) => _attemptFetch(url, query), classifyError: _classifyError, defaultPolicy: effectivePolicy, + serviceName: 'overpass', ); } - /// Single POST + parse attempt (no retry logic — handled by executeWithFallback). + /// Single POST + parse attempt (no retry logic — handled by executeWithEndpointList). Future> _attemptFetch(String endpoint, String query) async { debugPrint('[OverpassService] POST $endpoint'); diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index c8ae5a7c..b2eeab60 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -96,6 +96,7 @@ class RoutingService { execute: (url) => _postRoute(url, params), classifyError: _classifyError, defaultPolicy: _policy, + serviceName: 'routing', ); } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index b28c86ac..cc5b7588 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -316,6 +316,96 @@ class ResiliencePolicy { } } +/// In-memory counters for retry/fallback behavior. +/// +/// Metrics reset on app restart — no persistence or remote telemetry. +class ResilienceMetrics { + static final ResilienceMetrics _instance = ResilienceMetrics._(); + factory ResilienceMetrics() => _instance; + ResilienceMetrics._(); + + int _totalRequests = 0; + int _primarySuccesses = 0; + int _fallbackSuccesses = 0; + int _totalRetries = 0; + int _fallbackInvocations = 0; + int _aborts = 0; + int _totalFailures = 0; + final Map _errorsByType = {}; + Duration _totalLatency = Duration.zero; + int _latencySamples = 0; + + int get totalRequests => _totalRequests; + int get primarySuccesses => _primarySuccesses; + int get fallbackSuccesses => _fallbackSuccesses; + int get totalRetries => _totalRetries; + int get fallbackInvocations => _fallbackInvocations; + int get aborts => _aborts; + int get totalFailures => _totalFailures; + Map get errorsByType => Map.unmodifiable(_errorsByType); + double get avgLatencyMs => + _latencySamples > 0 ? _totalLatency.inMilliseconds / _latencySamples : 0.0; + + void recordRequest() => _totalRequests++; + void recordPrimarySuccess(Duration latency) { + _primarySuccesses++; + _recordLatency(latency); + } + void recordFallbackSuccess(Duration latency) { + _fallbackSuccesses++; + _recordLatency(latency); + } + void recordRetry() => _totalRetries++; + void recordFallbackInvocation() => _fallbackInvocations++; + void recordAbort() => _aborts++; + void recordFailure() => _totalFailures++; + void recordError(Object error) { + final key = error.runtimeType.toString(); + _errorsByType[key] = (_errorsByType[key] ?? 0) + 1; + } + void _recordLatency(Duration d) { + _totalLatency += d; + _latencySamples++; + } + + String get summary => 'ResilienceMetrics(' + 'requests: $_totalRequests, ' + 'primaryOk: $_primarySuccesses, ' + 'fallbackOk: $_fallbackSuccesses, ' + 'retries: $_totalRetries, ' + 'fallbacks: $_fallbackInvocations, ' + 'aborts: $_aborts, ' + 'failures: $_totalFailures, ' + 'avgLatency: ${avgLatencyMs.toStringAsFixed(0)}ms, ' + 'errors: $_errorsByType)'; + + void printSummary() => debugPrint('[Resilience] $summary'); + + @visibleForTesting + void reset() { + _totalRequests = 0; + _primarySuccesses = 0; + _fallbackSuccesses = 0; + _totalRetries = 0; + _fallbackInvocations = 0; + _aborts = 0; + _totalFailures = 0; + _errorsByType.clear(); + _totalLatency = Duration.zero; + _latencySamples = 0; + } +} + +/// Wraps an error with its already-classified [ErrorDisposition]. +/// +/// Thrown by [_executeWithRetries] so that callers can act on the disposition +/// without re-calling [classifyError] on the same error. +class _ClassifiedError { + final ErrorDisposition disposition; + final Object error; + _ClassifiedError(this.disposition, this.error); +} + /// Execute a request against an ordered list of endpoints with retry and fallback. /// /// Tries each enabled endpoint in list order. For each endpoint: @@ -326,19 +416,28 @@ class ResiliencePolicy { /// /// Throws [StateError] if no endpoints are enabled. /// Rethrows the last error if all endpoints are exhausted. +/// +/// [serviceName] is used in log messages. Defaults to the first endpoint's name. Future executeWithEndpointList({ required List endpoints, required Future Function(String url) execute, required ErrorDisposition Function(Object error) classifyError, ResiliencePolicy defaultPolicy = const ResiliencePolicy(), + String? serviceName, }) async { final enabled = endpoints.where((e) => e.enabled).toList(growable: false); if (enabled.isEmpty) { throw StateError('No enabled endpoints configured'); } + final label = serviceName ?? enabled.first.name; + final metrics = ResilienceMetrics(); + final stopwatch = Stopwatch()..start(); + metrics.recordRequest(); + Object? lastError; - for (final endpoint in enabled) { + for (int i = 0; i < enabled.length; i++) { + final endpoint = enabled[i]; final effectivePolicy = ResiliencePolicy( maxRetries: endpoint.maxRetries ?? defaultPolicy.maxRetries, httpTimeout: endpoint.timeoutSeconds != null @@ -348,71 +447,72 @@ Future executeWithEndpointList({ retryBackoffMaxMs: defaultPolicy.retryBackoffMaxMs, ); try { - return await _executeWithRetries( + final result = await _executeWithRetries( endpoint.url, execute, classifyError, effectivePolicy, + label, ); - } catch (e) { - final disposition = classifyError(e); - if (disposition == ErrorDisposition.abort) rethrow; - lastError = e; - debugPrint('[Resilience] Endpoint ${endpoint.name} failed ($e), trying next'); + if (i == 0) { + metrics.recordPrimarySuccess(stopwatch.elapsed); + } else { + metrics.recordFallbackSuccess(stopwatch.elapsed); + } + return result; + } on _ClassifiedError catch (ce) { + if (ce.disposition == ErrorDisposition.abort) { + metrics.recordAbort(); + throw ce.error; + } + lastError = ce.error; + if (i < enabled.length - 1) { + metrics.recordFallbackInvocation(); + debugPrint('[Resilience] $label | endpoint ${endpoint.name} failed | ' + 'trying ${enabled[i + 1].name}'); + } } } + metrics.recordFailure(); throw lastError!; // All endpoints exhausted } -/// Execute a request with retry and fallback logic. -/// -/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. -/// 2. On each failure, calls [classifyError] to determine disposition: -/// - [ErrorDisposition.abort]: rethrows immediately -/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available) -/// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted -/// 3. If [fallbackUrl] is non-null and the error is fallback-worthy, -/// repeats the retry loop against the fallback. -@Deprecated('Use executeWithEndpointList instead') -Future executeWithFallback({ - required String primaryUrl, - required String? fallbackUrl, - required Future Function(String url) execute, - required ErrorDisposition Function(Object error) classifyError, - ResiliencePolicy policy = const ResiliencePolicy(), -}) async { - try { - return await _executeWithRetries(primaryUrl, execute, classifyError, policy); - } catch (e) { - final disposition = classifyError(e); - if (disposition == ErrorDisposition.abort) rethrow; - if (fallbackUrl == null) rethrow; - debugPrint('[Resilience] Primary failed ($e), trying fallback'); - return _executeWithRetries(fallbackUrl, execute, classifyError, policy); - } -} - Future _executeWithRetries( String url, Future Function(String url) execute, ErrorDisposition Function(Object error) classifyError, ResiliencePolicy policy, + String label, ) async { - for (int attempt = 0; attempt <= policy.maxRetries; attempt++) { + final metrics = ResilienceMetrics(); + final maxAttempts = policy.maxRetries + 1; + for (int attempt = 0; attempt < maxAttempts; attempt++) { try { return await execute(url); } catch (e) { final disposition = classifyError(e); - if (disposition == ErrorDisposition.abort) rethrow; - if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback + metrics.recordError(e); + if (disposition == ErrorDisposition.abort) { + debugPrint('[Resilience] $label | attempt ${attempt + 1}/$maxAttempts ' + '| ${e.runtimeType} | disposition=abort'); + throw _ClassifiedError(disposition, e); + } + if (disposition == ErrorDisposition.fallback) { + debugPrint('[Resilience] $label | attempt ${attempt + 1}/$maxAttempts ' + '| ${e.runtimeType} | disposition=fallback'); + throw _ClassifiedError(disposition, e); + } // disposition == retry - if (attempt < policy.maxRetries) { + if (attempt + 1 < maxAttempts) { final delay = policy.retryDelay(attempt); - debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms'); + metrics.recordRetry(); + debugPrint('[Resilience] $label | attempt ${attempt + 1}/$maxAttempts ' + '| ${e.runtimeType} | disposition=retry | retrying in ${delay.inMilliseconds}ms'); await Future.delayed(delay); continue; } - rethrow; // retries exhausted, let caller try fallback + debugPrint('[Resilience] $label | exhausted $maxAttempts attempts'); + throw _ClassifiedError(disposition, e); } } throw StateError('Unreachable'); // loop always returns or throws diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index 4b6a2d41..300b7bf5 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -454,6 +454,10 @@ void main() { retryBackoffBase: Duration.zero, ); + setUp(() { + ResilienceMetrics().reset(); + }); + test('throws StateError when no endpoints enabled', () async { await expectLater( () => executeWithEndpointList( @@ -653,174 +657,141 @@ void main() { }); }); - // ignore: deprecated_member_use_from_same_package - group('executeWithFallback', () { - const policy = ResiliencePolicy( - maxRetries: 2, - retryBackoffBase: Duration.zero, // no delay in tests - ); - - test('abort error stops immediately, no fallback', () async { - int callCount = 0; + group('ResilienceMetrics', () { + setUp(() { + ResilienceMetrics().reset(); + }); - await expectLater( - () => executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: 'https://fallback.example.com', - execute: (url) { - callCount++; - throw Exception('bad request'); - }, - classifyError: (_) => ErrorDisposition.abort, - policy: policy, - ), - throwsA(isA()), + test('records primary success via endpoint list', () async { + await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ], + execute: (_) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, ); - expect(callCount, 1); // no retries, no fallback + final m = ResilienceMetrics(); + expect(m.totalRequests, 1); + expect(m.primarySuccesses, 1); + expect(m.fallbackSuccesses, 0); + expect(m.totalRetries, 0); + expect(m.fallbackInvocations, 0); + expect(m.aborts, 0); + expect(m.totalFailures, 0); }); - test('fallback error skips retries, goes to fallback', () async { - final urlsSeen = []; - - final result = await executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: 'https://fallback.example.com', + test('records fallback success via endpoint list', () async { + await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], execute: (url) { - urlsSeen.add(url); - if (url.contains('primary')) { - throw Exception('rate limited'); - } - return Future.value('ok from fallback'); + if (url == 'https://a.com') throw Exception('down'); + return Future.value('ok'); }, classifyError: (_) => ErrorDisposition.fallback, - policy: policy, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), ); - expect(result, 'ok from fallback'); - // 1 primary (no retries) + 1 fallback = 2 - expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']); + final m = ResilienceMetrics(); + expect(m.totalRequests, 1); + expect(m.primarySuccesses, 0); + expect(m.fallbackSuccesses, 1); + expect(m.fallbackInvocations, 1); }); - test('retry error retries N times then falls back', () async { - final urlsSeen = []; - - final result = await executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: 'https://fallback.example.com', - execute: (url) { - urlsSeen.add(url); - if (url.contains('primary')) { - throw Exception('server error'); - } - return Future.value('ok from fallback'); + test('records retries on transient failure then success', () async { + int callCount = 0; + await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ], + execute: (_) { + callCount++; + if (callCount < 3) throw Exception('transient'); + return Future.value('ok'); }, classifyError: (_) => ErrorDisposition.retry, - policy: policy, - ); - - expect(result, 'ok from fallback'); - // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 - expect(urlsSeen.where((u) => u.contains('primary')).length, 3); - expect(urlsSeen.where((u) => u.contains('fallback')).length, 1); - }); - - test('no fallback URL rethrows after retries', () async { - int callCount = 0; - - await expectLater( - () => executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: null, - execute: (url) { - callCount++; - throw Exception('server error'); - }, - classifyError: (_) => ErrorDisposition.retry, - policy: policy, + defaultPolicy: const ResiliencePolicy( + maxRetries: 2, + retryBackoffBase: Duration.zero, ), - throwsA(isA()), ); - // 3 attempts (1 + 2 retries), then rethrow - expect(callCount, 3); + final m = ResilienceMetrics(); + expect(m.totalRequests, 1); + expect(m.primarySuccesses, 1); + expect(m.totalRetries, 2); }); - test('fallback disposition with no fallback URL rethrows immediately', () async { - int callCount = 0; - - await expectLater( - () => executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: null, - execute: (url) { - callCount++; - throw Exception('rate limited'); - }, - classifyError: (_) => ErrorDisposition.fallback, - policy: policy, - ), - throwsA(isA()), - ); + test('records abort via endpoint list', () async { + try { + await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (_) => throw Exception('bad request'), + classifyError: (_) => ErrorDisposition.abort, + ); + } catch (_) {} - // Only 1 attempt — fallback disposition skips retries, and no fallback URL - expect(callCount, 1); + final m = ResilienceMetrics(); + expect(m.totalRequests, 1); + expect(m.aborts, 1); + expect(m.fallbackInvocations, 0); + expect(m.totalFailures, 0); }); - test('both fail propagates last error', () async { - await expectLater( - () => executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: 'https://fallback.example.com', - execute: (url) { - if (url.contains('fallback')) { - throw Exception('fallback also failed'); - } - throw Exception('primary failed'); - }, + test('records total failure when all endpoints fail', () async { + try { + await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (_) => throw Exception('down'), classifyError: (_) => ErrorDisposition.retry, - policy: policy, - ), - throwsA(isA().having( - (e) => e.toString(), 'message', contains('fallback also failed'))), - ); - }); - - test('success on first try returns immediately', () async { - int callCount = 0; - - final result = await executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: 'https://fallback.example.com', - execute: (url) { - callCount++; - return Future.value('success'); - }, - classifyError: (_) => ErrorDisposition.retry, - policy: policy, - ); + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ); + } catch (_) {} - expect(result, 'success'); - expect(callCount, 1); + final m = ResilienceMetrics(); + expect(m.totalRequests, 1); + expect(m.fallbackInvocations, 1); + expect(m.totalFailures, 1); + expect(m.primarySuccesses, 0); + expect(m.fallbackSuccesses, 0); }); - test('success after retry does not try fallback', () async { - int callCount = 0; + test('records error types', () async { + try { + await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ], + execute: (_) => throw FormatException('bad'), + classifyError: (_) => ErrorDisposition.abort, + ); + } catch (_) {} - final result = await executeWithFallback( - primaryUrl: 'https://primary.example.com', - fallbackUrl: 'https://fallback.example.com', - execute: (url) { - callCount++; - if (callCount == 1) throw Exception('transient'); - return Future.value('recovered'); - }, - classifyError: (_) => ErrorDisposition.retry, - policy: policy, - ); + final m = ResilienceMetrics(); + expect(m.errorsByType['FormatException'], 1); + }); - expect(result, 'recovered'); - expect(callCount, 2); // 1 fail + 1 success, no fallback + test('summary is human-readable', () { + final m = ResilienceMetrics(); + expect(m.summary, contains('requests:')); + expect(m.summary, contains('primaryOk:')); + expect(m.summary, contains('avgLatency:')); }); }); }