-
Notifications
You must be signed in to change notification settings - Fork 0
Dev Service Layer Fallback
Every external data source (country API, geocoder, charging map) is wrapped in a service chain that gives you:
- Fresh-first reads with request coalescing
- Explicit stale fallback
- Accumulated errors per attempt
- Structured source attribution
- Per-Dio rate limiting
lib/core/services/service_result.dart:34–59
class ServiceResult<T> {
final T data;
final ServiceSource source; // enum: tankerkoenigApi, prixCarburantsApi,
// eControlApi, cache, demo, ...
final DateTime fetchedAt;
final bool isStale;
final List<ServiceError> errors;
String get freshnessLabel; // "< 1 min", "5 min", "2 h", "3 d"
}
class ServiceError { ServiceSource source; String message; int? statusCode; DateTime occurredAt; }Every ServiceResult carries its provenance. The UI uses freshnessLabel and isStale for the per-result badge; it uses errors for a "Tried 3 sources" diagnostic summary.
lib/core/services/station_service_chain.dart:22
fetch(key, service):
1. cached = CacheManager.getFresh(key)
hit? return ServiceResult(cached.data, source: cache, isStale: false)
2. try service.fetch(params)
ok? CacheManager.put(key, result) and
return ServiceResult(data, source: service.source, isStale: false)
err? push to errors list, continue
3. stale = CacheManager.get(key)
hit? return ServiceResult(stale.data, source: cache, isStale: true, errors: [...])
4. throw ServiceChainExhaustedException(errors)
final Map<String, Future<ServiceResult<T>>> _inFlight = {};
final Map<String, DateTime> _inFlightTimestamps = {};If two widgets kick off the same fetch (e.g. map and list rebuilding concurrently), the second one attaches to the first Future instead of doubling the HTTP call. Entries auto-evict after 2 min.
lib/core/services/country_service_registry.dart:77–134
17 countries, each with a concrete StationService under lib/features/station_services/<country>/:
| Country | Service file | API base |
|---|---|---|
| DE | germany/tankerkoenig_station_service.dart |
creativecommons.tankerkoenig.de |
| FR | france/prix_carburants_station_service.dart |
data.gouv.fr |
| AT | austria/econtrol_station_service.dart |
e-control.at |
| ES | spain/miteco_station_service.dart |
sedeaplicaciones.mineco.gob.es |
| IT | italy/mise_station_service.dart |
dgsaie.mise.gov.it |
| DK | denmark/denmark_station_service.dart |
OK / Shell DK |
| PT | portugal/portugal_station_service.dart |
DGEG |
| LU | luxembourg/luxembourg_station_service.dart |
gouvernement.lu open data |
| SI | slovenia/slovenia_station_service.dart |
Petrol / OMV SI open data |
| GB | uk/uk_station_service.dart |
UK open data |
| AR | argentina/argentina_station_service.dart |
Energia Argentina (HTTPS enforced — #731) |
| AU | australia/australia_station_service.dart |
Government open data |
| MX | mexico/mexico_station_service.dart |
CRE |
| KR | south_korea/south_korea_station_service.dart |
Opinet |
| CL | chile/chile_station_service.dart |
Bencina en Línea |
| GR | greece/greece_station_service.dart |
Government open data |
| RO | romania/romania_station_service.dart |
Government open data |
Plus lib/core/services/impl/demo_station_service.dart for fallback / UI preview.
Resolve the right one with stationServiceProvider keyed on the active country:
@riverpod
StationService stationService(Ref ref) {
final country = ref.watch(activeCountryProvider);
return ref.watch(_stationServiceFor(country.code));
}lib/core/services/geocoding_chain.dart:24–30
Five-step chain (one more than stations):
- Fresh cache hit + coords inside country bounding box
-
NativeGeocodingProvider(Android/iOS built-in geocoder) -
NominatimGeocodingProvider(public OSM service) - Stale cache hit + bounding-box validation
- Throw
ServiceChainExhaustedException
Bounding-box validation is the extra step: Android's native geocoder occasionally returns coordinates in the wrong country for ambiguous ZIPs; we reject those and fall through to Nominatim.
lib/core/network/dio_factory.dart:15–48
Dio create({
required String baseUrl,
Duration? connectTimeout,
Duration? receiveTimeout,
RateLimitConfig? rateLimit = const RateLimitConfig.defaults(),
List<Interceptor>? extraInterceptors,
}) {
final dio = Dio(...);
if (rateLimit != null) dio.interceptors.add(RateLimitInterceptor(rateLimit));
// ...
}Every Dio gets a RateLimitInterceptor by default. Opt out (rateLimit: null) only for user-triggered one-shots where throttling hurts UX.
lib/core/network/rate_limit_interceptor.dart:18–61
- Per-Dio (not global) future gate
- Default
minInterval = 1 s, default jitter 500 ms - Serialises requests; if a call arrives before
minIntervalhas passed, it awaits - Jitter prevents thundering herd when the app wakes from background
| API | minInterval | jitter |
|---|---|---|
| Tankerkoenig (DE) | 2 s | 500–2500 ms |
| Nominatim | 1 s | 500 ms |
| OpenStreetMap tiles | 500 ms | 0 |
| OpenChargeMap | 1 s | 500 ms |
| Routing (OSRM) | 500 ms | 0 |
Set via DioFactory.create(..., rateLimit: RateLimitConfig(minInterval: 2 s, jitter: 2 s)).
Conform to the interface:
abstract class StationService {
ServiceSource get source;
Future<List<Station>> fetch(SearchParams params);
}Then register it in country_service_registry.dart with its country code. The chain, cache, rate limiting, and ServiceResult wrapping are given for free.
See Adding a Country for the step-by-step.
- Caching Strategy — how the cache layer works
- Error Reporting & Tracing — how ServiceChainExhaustedException flows to the trace recorder
👤 User Guide
🛠️ Developer Guide
Architecture
Code patterns
Quality
Deep dives
Reference
Workflow