mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-08 13:47:56 +02:00
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:
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user