Files
Doug Borg 2833906c68 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>
2026-03-11 23:13:52 -06:00

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.',
);
}
}
}