Skip to content

Commit 6ce7f29

Browse files
authored
feat(errors): add a cause to laundered exceptions (#168)
1 parent a83cd07 commit 6ce7f29

8 files changed

Lines changed: 336 additions & 34 deletions

helper/example/error_handling.dart

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// ignore_for_file: avoid_print
2+
3+
import 'package:algolia_helper_flutter/algolia_helper_flutter.dart';
4+
import 'package:algoliasearch/algoliasearch.dart' as algolia;
5+
6+
/// Example demonstrating how to handle exceptions with the new `cause` field.
7+
///
8+
/// The `SearchError.cause` field preserves the original exception, allowing
9+
/// developers to implement type-specific error handling and access detailed
10+
/// error information.
11+
void main() async {
12+
// Initialize the search client (use your own credentials)
13+
final searcher = HitsSearcher(
14+
applicationID: 'YOUR_APP_ID',
15+
apiKey: 'YOUR_API_KEY',
16+
indexName: 'products',
17+
);
18+
19+
// Example 1: Catching SearchError and accessing the original cause
20+
searcher.responses.listen(
21+
(response) {
22+
print('Search successful: ${response.hits.length} hits');
23+
},
24+
onError: (error) {
25+
if (error is SearchError) {
26+
print('Status code: ${error.statusCode}');
27+
print('Error details: ${error.error}');
28+
29+
// Access the original exception via the cause field
30+
if (error.cause != null) {
31+
print('Original cause: ${error.cause.runtimeType}');
32+
33+
// Handle specific exception types
34+
if (error.cause is algolia.AlgoliaApiException) {
35+
final apiException = error.cause as algolia.AlgoliaApiException;
36+
print('API Error: ${apiException.error}');
37+
print('Status code from original: ${apiException.statusCode}');
38+
39+
// Implement specific handling based on status code
40+
switch (apiException.statusCode) {
41+
case 401:
42+
print('Authentication error - check your API key');
43+
break;
44+
case 404:
45+
print('Index not found - check your index name');
46+
break;
47+
case 429:
48+
print('Rate limit exceeded - slow down requests');
49+
break;
50+
default:
51+
print('Other API error occurred');
52+
}
53+
} else if (error.cause is Exception) {
54+
final exception = error.cause as Exception;
55+
print('Generic exception: $exception');
56+
}
57+
}
58+
} else {
59+
print('Unexpected error: $error');
60+
}
61+
},
62+
);
63+
64+
// Example 2: Try-catch pattern with detailed error handling
65+
try {
66+
final response = await searcher.search('query');
67+
print('Found ${response.hits.length} results');
68+
} on SearchError catch (error) {
69+
// Handle SearchError specifically
70+
print('Search failed with status ${error.statusCode}');
71+
72+
// Check if it's a network error by examining the cause
73+
if (error.cause is algolia.AlgoliaApiException) {
74+
final apiError = error.cause as algolia.AlgoliaApiException;
75+
if (apiError.statusCode >= 500) {
76+
print('Server error - retry later');
77+
} else if (apiError.statusCode >= 400) {
78+
print('Client error - check request parameters');
79+
}
80+
}
81+
82+
// You can also implement custom retry logic based on the cause
83+
if (shouldRetry(error)) {
84+
print('Retrying...');
85+
// Implement retry logic
86+
}
87+
} catch (error) {
88+
print('Unexpected error: $error');
89+
}
90+
91+
searcher.dispose();
92+
}
93+
94+
/// Example function to determine if an error should be retried
95+
/// based on the original cause.
96+
bool shouldRetry(SearchError error) {
97+
// Don't retry authentication errors
98+
if (error.statusCode == 401 || error.statusCode == 403) {
99+
return false;
100+
}
101+
102+
// Retry server errors and timeouts
103+
if (error.statusCode >= 500) {
104+
return true;
105+
}
106+
107+
// Check the original cause for network issues
108+
if (error.cause != null) {
109+
final causeString = error.cause.toString().toLowerCase();
110+
if (causeString.contains('timeout') ||
111+
causeString.contains('network') ||
112+
causeString.contains('connection')) {
113+
return true;
114+
}
115+
}
116+
117+
return false;
118+
}

helper/lib/src/exception.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@ sealed class AlgoliaException implements Exception {}
77
final class SearchError extends AlgoliaException {
88
/// Creates [SearchError] instance.
99
@internal
10-
SearchError(this.error, this.statusCode);
10+
SearchError(this.error, this.statusCode, [this.cause]);
1111

1212
/// Error details (e.g. message)
1313
final Map error;
1414

1515
/// Response status code
1616
final int statusCode;
1717

18+
/// Original exception that caused this error, if any.
19+
/// This allows apps to inspect the underlying exception for detailed error handling.
20+
final Object? cause;
21+
1822
@override
19-
String toString() => 'SearchError{error: $error, statusCode: $statusCode}';
23+
String toString() =>
24+
'SearchError{error: $error, statusCode: $statusCode, cause: $cause}';
2025
}

helper/lib/src/service/algolia_client_extensions.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ extension ClientHelperAdapter on algolia.SearchClient {
1111
Exception launderException(dynamic error) =>
1212
error is algolia.AlgoliaApiException
1313
? error.toSearchError()
14-
: Exception(error);
14+
: SearchError({'message': error.toString()}, 0, error);
1515
}
1616

1717
/// Extensions over [AlgoliaException].
1818
extension AlgoliaExceptionExt on algolia.AlgoliaApiException {
1919
/// Converts API error to [SearchError].
2020
SearchError toSearchError() =>
21-
SearchError({'message': error.toString()}, statusCode);
21+
SearchError({'message': error.toString()}, statusCode, this);
2222
}
2323

2424
extension AlgolisSearchStateExt on SearchState {

helper/test/exception_test.dart

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import 'package:algolia_helper_flutter/src/exception.dart';
2+
import 'package:algoliasearch/algoliasearch.dart' as algolia;
3+
import 'package:algolia_helper_flutter/src/service/algolia_client_extensions.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
void main() {
7+
group('SearchError', () {
8+
test('should preserve original exception as cause', () {
9+
final originalError = Exception('Original error message');
10+
final searchError = SearchError(
11+
{'message': 'Search failed'},
12+
500,
13+
originalError,
14+
);
15+
16+
expect(searchError.error, {'message': 'Search failed'});
17+
expect(searchError.statusCode, 500);
18+
expect(searchError.cause, originalError);
19+
expect(identical(searchError.cause, originalError), true);
20+
});
21+
22+
test('should allow null cause', () {
23+
final searchError = SearchError(
24+
{'message': 'Search failed'},
25+
404,
26+
);
27+
28+
expect(searchError.error, {'message': 'Search failed'});
29+
expect(searchError.statusCode, 404);
30+
expect(searchError.cause, isNull);
31+
});
32+
33+
test('toString should include cause', () {
34+
final originalError = Exception('Network timeout');
35+
final searchError = SearchError(
36+
{'message': 'Request failed'},
37+
503,
38+
originalError,
39+
);
40+
41+
final stringRepresentation = searchError.toString();
42+
expect(stringRepresentation, contains('error:'));
43+
expect(stringRepresentation, contains('statusCode:'));
44+
expect(stringRepresentation, contains('cause:'));
45+
});
46+
});
47+
48+
group('launderException', () {
49+
// Create a dummy SearchClient instance for testing the extension method
50+
late algolia.SearchClient client;
51+
52+
setUp(() {
53+
// Create a minimal SearchClient instance
54+
// Note: In real usage, this would be properly configured
55+
client = algolia.SearchClient(
56+
appId: 'testAppId',
57+
apiKey: 'testApiKey',
58+
);
59+
});
60+
61+
test('should preserve non-AlgoliaApiException as cause', () {
62+
final originalError = Exception('Custom error');
63+
final launderedError = client.launderException(originalError);
64+
65+
expect(launderedError, isA<SearchError>());
66+
final searchError = launderedError as SearchError;
67+
expect(searchError.cause, originalError);
68+
expect(identical(searchError.cause, originalError), true);
69+
expect(searchError.error['message'], contains('Custom error'));
70+
});
71+
72+
test('should preserve AlgoliaApiException as cause', () {
73+
final originalError = algolia.AlgoliaApiException(
74+
401,
75+
'Invalid API key',
76+
);
77+
final launderedError = client.launderException(originalError);
78+
79+
expect(launderedError, isA<SearchError>());
80+
final searchError = launderedError as SearchError;
81+
expect(searchError.cause, originalError);
82+
expect(identical(searchError.cause, originalError), true);
83+
expect(searchError.statusCode, 401);
84+
});
85+
86+
test('should preserve any object type as cause', () {
87+
final originalError = StateError('Invalid state');
88+
final launderedError = client.launderException(originalError);
89+
90+
expect(launderedError, isA<SearchError>());
91+
final searchError = launderedError as SearchError;
92+
expect(searchError.cause, isA<StateError>());
93+
expect(identical(searchError.cause, originalError), true);
94+
});
95+
96+
test('should preserve string errors as cause', () {
97+
const originalError = 'Something went wrong';
98+
final launderedError = client.launderException(originalError);
99+
100+
expect(launderedError, isA<SearchError>());
101+
final searchError = launderedError as SearchError;
102+
expect(searchError.cause, originalError);
103+
expect(searchError.error['message'], contains(originalError));
104+
});
105+
});
106+
107+
group('AlgoliaExceptionExt', () {
108+
test('toSearchError should preserve original AlgoliaApiException', () {
109+
final apiException = algolia.AlgoliaApiException(
110+
404,
111+
'IndexNotFound',
112+
);
113+
114+
final searchError = apiException.toSearchError();
115+
116+
expect(searchError.statusCode, 404);
117+
expect(searchError.cause, apiException);
118+
expect(identical(searchError.cause, apiException), true);
119+
expect(searchError.error['message'], contains('IndexNotFound'));
120+
});
121+
});
122+
}

0 commit comments

Comments
 (0)