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:
Doug Borg
2026-03-11 23:13:52 -06:00
parent 4d1032e56d
commit 2833906c68
8 changed files with 968 additions and 270 deletions
+188 -12
View File
@@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/services/overpass_service.dart';
import 'package:deflockapp/services/service_policy.dart';
class MockHttpClient extends Mock implements http.Client {}
@@ -36,6 +37,7 @@ void main() {
setUp(() {
mockClient = MockHttpClient();
// Initialize OverpassService with a mock HTTP client for testing
service = OverpassService(client: mockClient);
});
@@ -246,9 +248,9 @@ void main() {
stubErrorResponse(
400, 'Error: too many nodes (limit is 50000) in query');
expect(
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0),
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()),
);
});
@@ -256,9 +258,9 @@ void main() {
test('response with "timeout" throws NodeLimitError', () async {
stubErrorResponse(400, 'runtime error: timeout in query execution');
expect(
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0),
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()),
);
});
@@ -267,9 +269,9 @@ void main() {
() async {
stubErrorResponse(400, 'runtime limit exceeded');
expect(
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0),
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()),
);
});
@@ -277,9 +279,9 @@ void main() {
test('HTTP 429 throws RateLimitError', () async {
stubErrorResponse(429, 'Too Many Requests');
expect(
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0),
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<RateLimitError>()),
);
});
@@ -287,9 +289,9 @@ void main() {
test('response with "rate limited" throws RateLimitError', () async {
stubErrorResponse(503, 'You are rate limited');
expect(
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0),
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<RateLimitError>()),
);
});
@@ -298,9 +300,9 @@ void main() {
() async {
stubErrorResponse(500, 'Internal Server Error');
expect(
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0),
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
});
@@ -313,4 +315,178 @@ void main() {
verifyNever(() => mockClient.post(any(), body: any(named: 'body')));
});
});
group('fallback behavior', () {
test('falls back to overpass-api.de on NetworkError after retries', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Internal Server Error', 500);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0));
expect(nodes, hasLength(1));
// primary (1 attempt, 0 retries) + fallback (1 attempt) = 2
expect(callCount, equals(2));
});
test('does NOT fallback on NodeLimitError', () async {
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async => http.Response(
'Error: too many nodes (limit is 50000) in query',
400,
));
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()),
);
// Only one call — no fallback (abort disposition)
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(1);
});
test('RateLimitError triggers fallback without retrying primary', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Too Many Requests', 429);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
expect(nodes, hasLength(1));
// 1 primary (no retry on fallback disposition) + 1 fallback = 2
expect(callCount, equals(2));
});
test('primary fails then fallback also fails -> error propagated', () async {
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async =>
http.Response('Internal Server Error', 500));
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
// primary + fallback
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(2);
});
test('does NOT fallback when using custom endpoint', () async {
final customService = OverpassService(
client: mockClient,
endpoint: 'https://custom.example.com/api/interpreter',
);
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async =>
http.Response('Internal Server Error', 500));
await expectLater(
() => customService.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
// Only one call - no fallback with custom endpoint
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(1);
});
test('retries exhaust before fallback kicks in', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Server Error', 500);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
expect(nodes, hasLength(1));
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
expect(callCount, equals(4));
});
});
group('default endpoints', () {
test('default endpoint is overpass.deflock.org', () {
expect(OverpassService.defaultEndpoint,
equals('https://overpass.deflock.org/api/interpreter'));
});
test('fallback endpoint is overpass-api.de', () {
expect(OverpassService.fallbackEndpoint,
equals('https://overpass-api.de/api/interpreter'));
});
});
}
+324 -27
View File
@@ -41,6 +41,30 @@ void main() {
AppState.instance = MockAppState();
});
/// Helper: stub a successful routing response
void stubSuccessResponse() {
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
));
}
group('RoutingService', () {
test('empty tags are filtered from request body', () async {
// Profile with empty tag values (like builtin-flock has camera:mount: '')
@@ -57,29 +81,7 @@ void main() {
];
when(() => mockAppState.enabledProfiles).thenReturn(profiles);
// Capture the request body
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
);
});
stubSuccessResponse();
await service.calculateRoute(start: start, end: end);
@@ -147,7 +149,7 @@ void main() {
reasonPhrase: 'Bad Request',
));
expect(
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.message,
@@ -166,7 +168,7 @@ void main() {
body: any(named: 'body'),
)).thenThrow(http.ClientException('Connection refused'));
expect(
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.message,
@@ -176,7 +178,7 @@ void main() {
);
});
test('API-level error surfaces alprwatch message', () async {
test('API-level error surfaces message', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
@@ -191,7 +193,7 @@ void main() {
200,
));
expect(
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.message,
@@ -201,4 +203,299 @@ void main() {
);
});
});
group('fallback behavior', () {
test('falls back to secondary on server error (500) after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Internal Server Error', 500,
reasonPhrase: 'Internal Server Error');
}
// Fallback succeeds
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 5000.0,
'duration': 300.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(5000.0));
// 2 primary attempts (1 + 1 retry) + 1 fallback = 3
expect(callCount, equals(3));
});
test('falls back on 502 (GraphHopper unavailable) after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Bad Gateway', 502, reasonPhrase: 'Bad Gateway');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 100.0,
'duration': 60.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(100.0));
// 2 primary attempts + 1 fallback = 3
expect(callCount, equals(3));
});
test('falls back on network error after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
throw http.ClientException('Connection refused');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 100.0,
'duration': 60.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(100.0));
// 2 primary attempts + 1 fallback = 3
expect(callCount, equals(3));
});
test('429 triggers fallback without retrying primary', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Too Many Requests', 429,
reasonPhrase: 'Too Many Requests');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 200.0,
'duration': 120.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(200.0));
// 1 primary (no retry on 429/fallback disposition) + 1 fallback = 2
expect(callCount, equals(2));
});
test('does NOT fallback on 400 (validation error)', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Bad Request: missing start', 400,
reasonPhrase: 'Bad Request'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 400)),
);
// Only one call — no retry, no fallback (abort disposition)
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('does NOT fallback on 403 (all 4xx except 429 abort)', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Forbidden', 403,
reasonPhrase: 'Forbidden'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 403)),
);
// Only one call — no retry, no fallback (abort disposition)
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('does NOT fallback on API-level business logic errors', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': false,
'error': 'No route found',
}),
200,
));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.isApiError, 'isApiError', true)),
);
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('primary fails then fallback also fails -> error propagated', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Internal Server Error', 500,
reasonPhrase: 'Internal Server Error'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 500)),
);
// 2 primary attempts + 2 fallback attempts = 4
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(4);
});
test('does NOT fallback when using custom baseUrl', () async {
final customService = RoutingService(
client: mockClient,
baseUrl: 'https://custom.example.com/route',
);
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Service Unavailable', 503,
reasonPhrase: 'Service Unavailable'));
await expectLater(
() => customService.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>()),
);
// 2 attempts (1 + 1 retry), no fallback with custom URL
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(2);
});
});
group('RoutingException', () {
test('statusCode is preserved', () {
const e = RoutingException('test', statusCode: 502);
expect(e.statusCode, 502);
expect(e.isApiError, false);
});
test('isApiError flag works', () {
const e = RoutingException('test', isApiError: true);
expect(e.isApiError, true);
expect(e.statusCode, isNull);
});
});
}
+193 -61
View File
@@ -5,10 +5,6 @@ import 'package:deflockapp/services/service_policy.dart';
void main() {
group('ServicePolicyResolver', () {
setUp(() {
ServicePolicyResolver.clearCustomPolicies();
});
group('resolveType', () {
test('resolves OSM editing API from production URL', () {
expect(
@@ -200,63 +196,6 @@ void main() {
});
});
group('custom policy overrides', () {
test('custom override takes precedence over built-in', () {
ServicePolicyResolver.registerCustomPolicy(
'overpass-api.de',
const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true),
);
final policy = ServicePolicyResolver.resolve(
'https://overpass-api.de/api/interpreter',
);
expect(policy.maxConcurrentRequests, 20);
});
test('custom policy for self-hosted tiles allows offline', () {
ServicePolicyResolver.registerCustomPolicy(
'tiles.myserver.com',
const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16),
);
final policy = ServicePolicyResolver.resolve(
'https://tiles.myserver.com/{z}/{x}/{y}.png',
);
expect(policy.allowsOfflineDownload, true);
expect(policy.maxConcurrentRequests, 16);
});
test('removing custom override restores built-in policy', () {
ServicePolicyResolver.registerCustomPolicy(
'overpass-api.de',
const ServicePolicy.custom(maxConcurrent: 20),
);
expect(
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
20,
);
ServicePolicyResolver.removeCustomPolicy('overpass-api.de');
// Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere)
expect(
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
0,
);
});
test('clearCustomPolicies removes all overrides', () {
ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1));
ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2));
ServicePolicyResolver.clearCustomPolicies();
// Both should now return custom (default) policy
expect(
ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests,
8, // default custom maxConcurrent
);
});
});
});
group('ServiceRateLimiter', () {
@@ -423,4 +362,197 @@ void main() {
expect(policy.attributionUrl, 'https://example.com/license');
});
});
group('ResiliencePolicy', () {
test('retryDelay uses exponential backoff', () {
const policy = ResiliencePolicy(
retryBackoffBase: Duration(milliseconds: 100),
retryBackoffMaxMs: 2000,
);
expect(policy.retryDelay(0), const Duration(milliseconds: 100));
expect(policy.retryDelay(1), const Duration(milliseconds: 200));
expect(policy.retryDelay(2), const Duration(milliseconds: 400));
});
test('retryDelay clamps to max', () {
const policy = ResiliencePolicy(
retryBackoffBase: Duration(milliseconds: 1000),
retryBackoffMaxMs: 3000,
);
expect(policy.retryDelay(0), const Duration(milliseconds: 1000));
expect(policy.retryDelay(1), const Duration(milliseconds: 2000));
expect(policy.retryDelay(2), const Duration(milliseconds: 3000)); // clamped
expect(policy.retryDelay(10), const Duration(milliseconds: 3000)); // clamped
});
});
group('executeWithFallback', () {
const policy = ResiliencePolicy(
maxRetries: 2,
retryBackoffBase: Duration.zero, // no delay in tests
);
test('abort error stops immediately, no fallback', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
throw Exception('bad request');
},
classifyError: (_) => ErrorDisposition.abort,
policy: policy,
),
throwsA(isA<Exception>()),
);
expect(callCount, 1); // no retries, no fallback
});
test('fallback error skips retries, goes to fallback', () async {
final urlsSeen = <String>[];
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
urlsSeen.add(url);
if (url.contains('primary')) {
throw Exception('rate limited');
}
return Future.value('ok from fallback');
},
classifyError: (_) => ErrorDisposition.fallback,
policy: policy,
);
expect(result, 'ok from fallback');
// 1 primary (no retries) + 1 fallback = 2
expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']);
});
test('retry error retries N times then falls back', () async {
final urlsSeen = <String>[];
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
urlsSeen.add(url);
if (url.contains('primary')) {
throw Exception('server error');
}
return Future.value('ok from fallback');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'ok from fallback');
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
expect(urlsSeen.where((u) => u.contains('primary')).length, 3);
expect(urlsSeen.where((u) => u.contains('fallback')).length, 1);
});
test('no fallback URL rethrows after retries', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: null,
execute: (url) {
callCount++;
throw Exception('server error');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
),
throwsA(isA<Exception>()),
);
// 3 attempts (1 + 2 retries), then rethrow
expect(callCount, 3);
});
test('fallback disposition with no fallback URL rethrows immediately', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: null,
execute: (url) {
callCount++;
throw Exception('rate limited');
},
classifyError: (_) => ErrorDisposition.fallback,
policy: policy,
),
throwsA(isA<Exception>()),
);
// Only 1 attempt — fallback disposition skips retries, and no fallback URL
expect(callCount, 1);
});
test('both fail propagates last error', () async {
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
if (url.contains('fallback')) {
throw Exception('fallback also failed');
}
throw Exception('primary failed');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
),
throwsA(isA<Exception>().having(
(e) => e.toString(), 'message', contains('fallback also failed'))),
);
});
test('success on first try returns immediately', () async {
int callCount = 0;
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
return Future.value('success');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'success');
expect(callCount, 1);
});
test('success after retry does not try fallback', () async {
int callCount = 0;
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
if (callCount == 1) throw Exception('transient');
return Future.value('recovered');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'recovered');
expect(callCount, 2); // 1 fail + 1 success, no fallback
});
});
}