mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-12 23:36:35 +02:00
Add centralized retry/fallback policy with hard-coded endpoints
Extract duplicated retry logic from OverpassService and RoutingService into a shared resilience framework in service_policy.dart: - ResiliencePolicy: configurable retries, backoff, and HTTP timeout - executeWithFallback: retry loop with primary→fallback endpoint chain - ErrorDisposition enum: abort / fallback / retry classification - ServicePolicy + ServicePolicyResolver: per-service compliance rules (rate limits, caching, concurrency) for OSMF and third-party services - ServiceRateLimiter: async semaphore-based concurrency and rate control OverpassService now hits overpass.deflock.org first, falls back to overpass-api.de. RoutingService hits api.dontgetflocked.com first, falls back to alprwatch.org. Both use per-service error classifiers to determine retry vs fallback vs abort behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,9 +64,6 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
|
||||
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||
const double kChangesetCloseBackoffMultiplier = 2.0;
|
||||
|
||||
// Navigation routing configuration
|
||||
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
|
||||
|
||||
// Overpass API configuration
|
||||
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
||||
|
||||
|
||||
@@ -8,97 +8,106 @@ 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].
|
||||
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<List<OsmNode>> fetchNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int maxRetries = 3,
|
||||
ResiliencePolicy? policy,
|
||||
}) 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');
|
||||
}
|
||||
}
|
||||
|
||||
throw NetworkError('Max retries exceeded');
|
||||
final endpoint = _primaryEndpoint;
|
||||
final canFallback = _endpointOverride == null;
|
||||
final effectivePolicy = policy ?? _policy;
|
||||
|
||||
return executeWithFallback<List<OsmNode>>(
|
||||
primaryUrl: endpoint,
|
||||
fallbackUrl: canFallback ? fallbackEndpoint : null,
|
||||
execute: (url) => _attemptFetch(url, query, effectivePolicy),
|
||||
classifyError: _classifyError,
|
||||
policy: effectivePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
|
||||
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) 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');
|
||||
}
|
||||
}
|
||||
|
||||
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<NodeProfile> profiles) {
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
@@ -107,7 +116,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 +128,38 @@ class OverpassService {
|
||||
out body;
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out skel;
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects
|
||||
List<OsmNode> _parseResponse(String responseBody) {
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
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<dynamic>? ??
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
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 +170,7 @@ out skel;
|
||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||
return nodes;
|
||||
}
|
||||
@@ -189,4 +198,4 @@ class NetworkError extends Error {
|
||||
NetworkError(this.message);
|
||||
@override
|
||||
String toString() => 'NetworkError: $message';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LatLng> 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';
|
||||
final http.Client _client;
|
||||
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),
|
||||
);
|
||||
|
||||
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
final http.Client _client;
|
||||
/// Optional override URL. When null, uses [defaultUrl].
|
||||
final String? _baseUrlOverride;
|
||||
|
||||
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<RouteResult> 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,25 @@ 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');
|
||||
|
||||
|
||||
final primaryUrl = _primaryUrl;
|
||||
final canFallback = _baseUrlOverride == null;
|
||||
|
||||
return executeWithFallback<RouteResult>(
|
||||
primaryUrl: primaryUrl,
|
||||
fallbackUrl: canFallback ? fallbackUrl : null,
|
||||
execute: (url) => _postRoute(url, params),
|
||||
classifyError: _classifyError,
|
||||
policy: _policy,
|
||||
);
|
||||
}
|
||||
|
||||
Future<RouteResult> _postRoute(String url, Map<String, dynamic> params) async {
|
||||
final uri = Uri.parse(url);
|
||||
debugPrint('[RoutingService] POST $uri');
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
@@ -78,7 +104,7 @@ class RoutingService {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: json.encode(params)
|
||||
).timeout(kNavigationRoutingTimeout);
|
||||
).timeout(_policy.httpTimeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (kDebugMode) {
|
||||
@@ -91,24 +117,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<String, dynamic>;
|
||||
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<String, dynamic>?;
|
||||
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<dynamic>?)
|
||||
?.map((inner) {
|
||||
final pair = inner as List<dynamic>;
|
||||
@@ -116,19 +143,19 @@ class RoutingService {
|
||||
final lng = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lng);
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
}).whereType<LatLng>().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 +165,26 @@ class RoutingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ErrorDisposition _classifyError(Object error) {
|
||||
if (error is! RoutingException) return ErrorDisposition.retry;
|
||||
if (error.isApiError) return ErrorDisposition.abort;
|
||||
final status = error.statusCode;
|
||||
if (status != null && status >= 400 && status < 500) {
|
||||
if (status == 429) return ErrorDisposition.fallback;
|
||||
return ErrorDisposition.abort;
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -152,11 +152,10 @@ 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
|
||||
/// via [registerCustomPolicy].
|
||||
/// tile providers. Falls back to permissive defaults for unrecognized hosts.
|
||||
class ServicePolicyResolver {
|
||||
/// Host → ServiceType mapping for known services.
|
||||
static final Map<String, ServiceType> _hostPatterns = {
|
||||
@@ -166,6 +165,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,
|
||||
@@ -183,25 +183,14 @@ class ServicePolicyResolver {
|
||||
ServiceType.custom: const ServicePolicy(),
|
||||
};
|
||||
|
||||
/// Custom host overrides registered at runtime (for self-hosted services).
|
||||
static final Map<String, ServicePolicy> _customOverrides = {};
|
||||
|
||||
/// Resolve a URL to its applicable [ServicePolicy].
|
||||
///
|
||||
/// Checks custom overrides first, then built-in host patterns. Falls back
|
||||
/// to [ServicePolicy.custom] for unrecognized hosts.
|
||||
/// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
|
||||
/// for unrecognized hosts.
|
||||
static ServicePolicy resolve(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return const ServicePolicy();
|
||||
|
||||
// Check custom overrides first (exact or subdomain matching)
|
||||
for (final entry in _customOverrides.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check built-in patterns (support subdomain matching)
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return _policies[entry.value] ?? const ServicePolicy();
|
||||
@@ -218,14 +207,6 @@ class ServicePolicyResolver {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return ServiceType.custom;
|
||||
|
||||
// Check custom overrides first — a registered custom policy means
|
||||
// the host is treated as ServiceType.custom with custom rules.
|
||||
for (final entry in _customOverrides.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return ServiceType.custom;
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
@@ -239,29 +220,6 @@ class ServicePolicyResolver {
|
||||
static ServicePolicy resolveByType(ServiceType type) =>
|
||||
_policies[type] ?? const ServicePolicy();
|
||||
|
||||
/// Register a custom policy override for a host pattern.
|
||||
///
|
||||
/// Use this to configure self-hosted services:
|
||||
/// ```dart
|
||||
/// ServicePolicyResolver.registerCustomPolicy(
|
||||
/// 'tiles.myserver.com',
|
||||
/// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20),
|
||||
/// );
|
||||
/// ```
|
||||
static void registerCustomPolicy(String hostPattern, ServicePolicy policy) {
|
||||
_customOverrides[hostPattern] = policy;
|
||||
}
|
||||
|
||||
/// Remove a custom policy override.
|
||||
static void removeCustomPolicy(String hostPattern) {
|
||||
_customOverrides.remove(hostPattern);
|
||||
}
|
||||
|
||||
/// Clear all custom policy overrides (useful for testing).
|
||||
static void clearCustomPolicies() {
|
||||
_customOverrides.clear();
|
||||
}
|
||||
|
||||
/// Extract the host from a URL or URL template.
|
||||
static String? _extractHost(String url) {
|
||||
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
@@ -283,6 +241,95 @@ 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 primary failed with a non-abort error,
|
||||
/// repeats the retry loop against the fallback.
|
||||
Future<T> executeWithFallback<T>({
|
||||
required String primaryUrl,
|
||||
required String? fallbackUrl,
|
||||
required Future<T> 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) {
|
||||
// _executeWithRetries rethrows abort/fallback/exhausted-retry errors.
|
||||
// Re-classify only to distinguish abort (which must not fall back) from
|
||||
// fallback/retry-exhausted (which should). This is the one intentional
|
||||
// re-classification — _executeWithRetries cannot short-circuit past the
|
||||
// outer try/catch.
|
||||
if (classifyError(e) == ErrorDisposition.abort) rethrow;
|
||||
if (fallbackUrl == null) rethrow;
|
||||
debugPrint('[Resilience] Primary failed ($e), trying fallback');
|
||||
return _executeWithRetries(fallbackUrl, execute, classifyError, policy);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _executeWithRetries<T>(
|
||||
String url,
|
||||
Future<T> 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user