mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-04-21 19:26:31 +02:00
2833906c68
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>
450 lines
16 KiB
Dart
450 lines
16 KiB
Dart
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<String, ServiceType> _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<ServiceType, ServicePolicy> _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<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
|
|
/// 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<ServiceType, DateTime> _lastRequestTime = {};
|
|
|
|
/// Per-service concurrency semaphores.
|
|
static final Map<ServiceType, _Semaphore> _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<void> 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<Completer<void>> _waiters = [];
|
|
|
|
_Semaphore(this._maxCount);
|
|
|
|
Future<void> acquire() async {
|
|
if (_currentCount < _maxCount) {
|
|
_currentCount++;
|
|
return;
|
|
}
|
|
final completer = Completer<void>();
|
|
_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.',
|
|
);
|
|
}
|
|
}
|
|
}
|