import 'dart:async'; import 'package:flutter/foundation.dart'; /// Identifies the type of external service being accessed. /// Used by [ServicePolicyResolver] to determine the correct compliance policy. enum ServiceType { // OSMF official services osmEditingApi, // api.openstreetmap.org — editing & data queries osmTileServer, // tile.openstreetmap.org — raster tiles nominatim, // nominatim.openstreetmap.org — geocoding overpass, // overpass-api.de — read-only data queries tagInfo, // taginfo.openstreetmap.org — tag metadata // Third-party tile services bingTiles, // *.tiles.virtualearth.net mapboxTiles, // api.mapbox.com // Everything else custom, // user's own infrastructure / unknown } /// Defines the compliance rules for a specific service. /// /// Each policy captures the rate limits, caching requirements, offline /// permissions, and attribution obligations mandated by the service operator. /// When the app talks to official OSMF infrastructure the strict policies /// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom] /// provides permissive defaults. class ServicePolicy { /// Max concurrent HTTP connections to this service. /// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114). final int maxConcurrentRequests; /// Minimum interval between consecutive requests. Null means no rate limit. final Duration? minRequestInterval; /// Whether this endpoint permits offline/bulk downloading of tiles. final bool allowsOfflineDownload; /// Whether the client must cache responses (e.g., Nominatim policy). final bool requiresClientCaching; /// Minimum cache TTL to enforce regardless of server headers. /// Null means "use server-provided max-age as-is". final Duration? minCacheTtl; /// License/attribution URL to display in the attribution dialog. /// Null means no special attribution link is needed. final String? attributionUrl; const ServicePolicy({ this.maxConcurrentRequests = 8, this.minRequestInterval, this.allowsOfflineDownload = true, this.requiresClientCaching = false, this.minCacheTtl, this.attributionUrl, }); /// OSM editing API (api.openstreetmap.org) /// Policy: max 2 concurrent download threads. /// https://operations.osmfoundation.org/policies/api/ const ServicePolicy.osmEditingApi() : maxConcurrentRequests = 2, minRequestInterval = null, allowsOfflineDownload = true, // n/a for API requiresClientCaching = false, minCacheTtl = null, attributionUrl = null; /// OSM tile server (tile.openstreetmap.org) /// Policy: min 7-day cache, must honor cache headers. /// Concurrency managed by flutter_map's NetworkTileProvider. /// https://operations.osmfoundation.org/policies/tiles/ const ServicePolicy.osmTileServer() : maxConcurrentRequests = 0, // managed by flutter_map minRequestInterval = null, allowsOfflineDownload = true, requiresClientCaching = true, minCacheTtl = const Duration(days: 7), attributionUrl = 'https://www.openstreetmap.org/copyright'; /// Nominatim geocoding (nominatim.openstreetmap.org) /// Policy: max 1 req/sec, single machine only, results must be cached. /// https://operations.osmfoundation.org/policies/nominatim/ const ServicePolicy.nominatim() : maxConcurrentRequests = 1, minRequestInterval = const Duration(seconds: 1), allowsOfflineDownload = true, // n/a for geocoding requiresClientCaching = true, minCacheTtl = null, attributionUrl = 'https://www.openstreetmap.org/copyright'; /// Overpass API (overpass-api.de) /// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore. const ServicePolicy.overpass() : maxConcurrentRequests = 0, // managed by NodeDataManager minRequestInterval = null, // managed by NodeDataManager allowsOfflineDownload = true, // n/a for data queries requiresClientCaching = false, minCacheTtl = null, attributionUrl = null; /// TagInfo API (taginfo.openstreetmap.org) const ServicePolicy.tagInfo() : maxConcurrentRequests = 2, minRequestInterval = null, allowsOfflineDownload = true, // n/a requiresClientCaching = true, // already cached in NSIService minCacheTtl = null, attributionUrl = null; /// Bing Maps tiles (*.tiles.virtualearth.net) const ServicePolicy.bingTiles() : maxConcurrentRequests = 0, // managed by flutter_map minRequestInterval = null, allowsOfflineDownload = true, // check Bing ToS separately requiresClientCaching = false, minCacheTtl = null, attributionUrl = null; /// Mapbox tiles (api.mapbox.com) const ServicePolicy.mapboxTiles() : maxConcurrentRequests = 0, // managed by flutter_map minRequestInterval = null, allowsOfflineDownload = true, // permitted with valid token requiresClientCaching = false, minCacheTtl = null, attributionUrl = null; /// Custom/self-hosted service — permissive defaults. const ServicePolicy.custom({ int maxConcurrent = 8, bool allowsOffline = true, Duration? minInterval, String? attribution, }) : maxConcurrentRequests = maxConcurrent, minRequestInterval = minInterval, allowsOfflineDownload = allowsOffline, requiresClientCaching = false, minCacheTtl = null, attributionUrl = attribution; @override String toString() => 'ServicePolicy(' 'maxConcurrent: $maxConcurrentRequests, ' 'minInterval: $minRequestInterval, ' 'offlineDownload: $allowsOfflineDownload, ' 'clientCaching: $requiresClientCaching, ' 'minCacheTtl: $minCacheTtl, ' 'attributionUrl: $attributionUrl)'; } /// Resolves service URLs to their applicable [ServicePolicy]. /// /// Built-in patterns cover all OSMF official services and common third-party /// tile providers. Falls back to permissive defaults for unrecognized hosts. class ServicePolicyResolver { /// Host → ServiceType mapping for known services. static final Map _hostPatterns = { 'api.openstreetmap.org': ServiceType.osmEditingApi, 'api06.dev.openstreetmap.org': ServiceType.osmEditingApi, 'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi, '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, }; /// ServiceType → policy mapping. static final Map _policies = { ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(), ServiceType.osmTileServer: const ServicePolicy.osmTileServer(), ServiceType.nominatim: const ServicePolicy.nominatim(), ServiceType.overpass: const ServicePolicy.overpass(), ServiceType.tagInfo: const ServicePolicy.tagInfo(), ServiceType.bingTiles: const ServicePolicy.bingTiles(), ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(), ServiceType.custom: const ServicePolicy(), }; /// Resolve a URL to its applicable [ServicePolicy]. /// /// 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(); for (final entry in _hostPatterns.entries) { if (host == entry.key || host.endsWith('.${entry.key}')) { return _policies[entry.value] ?? const ServicePolicy(); } } return const ServicePolicy(); } /// Resolve a URL to its [ServiceType]. /// /// Returns [ServiceType.custom] for unrecognized hosts. static ServiceType resolveType(String url) { final host = _extractHost(url); if (host == null) return ServiceType.custom; for (final entry in _hostPatterns.entries) { if (host == entry.key || host.endsWith('.${entry.key}')) { return entry.value; } } return ServiceType.custom; } /// Look up the [ServicePolicy] for a known [ServiceType]. static ServicePolicy resolveByType(ServiceType type) => _policies[type] ?? const ServicePolicy(); /// 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' // and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...' try { // Strip template variables from subdomain part for parsing final cleaned = url .replaceAll(RegExp(r'\{0_3\}'), '0') .replaceAll(RegExp(r'\{1_4\}'), '1') .replaceAll(RegExp(r'\{quadkey\}'), 'quadkey') .replaceAll(RegExp(r'\{z\}'), '0') .replaceAll(RegExp(r'\{x\}'), '0') .replaceAll(RegExp(r'\{y\}'), '0') .replaceAll(RegExp(r'\{api_key\}'), 'key'); return Uri.parse(cleaned).host.toLowerCase(); } catch (_) { return null; } } } /// 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 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) { // _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 _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 /// service's [ServicePolicy]. Call [acquire] before making a request and /// [release] after the request completes. /// /// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0 /// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere /// (flutter_map, PR #114) are passed through without blocking. class ServiceRateLimiter { /// Injectable clock for testing. Defaults to [DateTime.now]. /// /// Override with a deterministic clock (e.g. from `FakeAsync`) so tests /// don't rely on wall-clock time and stay fast and stable under CI load. @visibleForTesting static DateTime Function() clock = DateTime.now; /// Per-service timestamps of the last acquired request slot / request start /// (used for rate limiting in [acquire], not updated on completion). static final Map _lastRequestTime = {}; /// Per-service concurrency semaphores. static final Map _semaphores = {}; /// Acquire a slot: wait for rate limit compliance, then take a connection slot. /// /// Blocks if: /// 1. The minimum interval between requests hasn't elapsed yet, or /// 2. All concurrent connection slots are in use. static Future acquire(ServiceType service) async { final policy = ServicePolicyResolver.resolveByType(service); // Concurrency: acquire a semaphore slot first so that at most // [policy.maxConcurrentRequests] callers proceed concurrently. // The min-interval check below is only race-free when // maxConcurrentRequests == 1 (currently only Nominatim). For services // with higher concurrency the interval is approximate, which is // acceptable — their policies don't specify a min interval. _Semaphore? semaphore; if (policy.maxConcurrentRequests > 0) { semaphore = _semaphores.putIfAbsent( service, () => _Semaphore(policy.maxConcurrentRequests), ); await semaphore.acquire(); } try { // Rate limit: wait if we sent a request too recently if (policy.minRequestInterval != null) { final lastTime = _lastRequestTime[service]; if (lastTime != null) { final elapsed = clock().difference(lastTime); final remaining = policy.minRequestInterval! - elapsed; if (remaining > Duration.zero) { debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms'); await Future.delayed(remaining); } } } // Record request time _lastRequestTime[service] = clock(); } catch (_) { // Release the semaphore slot if the rate-limit delay fails, // to avoid permanently leaking a slot. semaphore?.release(); rethrow; } } /// Release a connection slot after request completes. static void release(ServiceType service) { _semaphores[service]?.release(); } /// Reset all rate limiter state (for testing). @visibleForTesting static void reset() { _lastRequestTime.clear(); _semaphores.clear(); clock = DateTime.now; } } /// Simple async counting semaphore for concurrency limiting. class _Semaphore { final int _maxCount; int _currentCount = 0; final List> _waiters = []; _Semaphore(this._maxCount); Future acquire() async { if (_currentCount < _maxCount) { _currentCount++; return; } final completer = Completer(); _waiters.add(completer); await completer.future; } void release() { if (_waiters.isNotEmpty) { final next = _waiters.removeAt(0); next.complete(); } else if (_currentCount > 0) { _currentCount--; } else { throw StateError( 'Semaphore.release() called more times than acquire(); ' 'currentCount is already zero.', ); } } }