505 lines
10 KiB
Markdown
505 lines
10 KiB
Markdown
|
|
# Error Handling
|
||
|
|
|
||
|
|
> How errors are handled in Flutter app.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This app follows **RFC 7807 Problem Details** for error handling:
|
||
|
|
|
||
|
|
- **Error model**: `ApiProblem` class
|
||
|
|
- **Error parsing**: `api_problem_mapper.dart` maps HTTP errors to `ApiProblem`
|
||
|
|
- **Error codes**: Machine-readable codes from `docs/protocols/common/http-error-codes.md`
|
||
|
|
- **User messages**: Safe, localized messages via `l10n`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Error Types
|
||
|
|
|
||
|
|
### `ApiProblem`
|
||
|
|
|
||
|
|
**Custom exception for HTTP API errors:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// core/network/api_problem.dart
|
||
|
|
class ApiProblem implements Exception {
|
||
|
|
ApiProblem({
|
||
|
|
required this.status,
|
||
|
|
required this.title,
|
||
|
|
required this.detail,
|
||
|
|
this.code,
|
||
|
|
});
|
||
|
|
|
||
|
|
final int status;
|
||
|
|
final String title;
|
||
|
|
final String detail;
|
||
|
|
final String? code;
|
||
|
|
|
||
|
|
String toUserMessage() {
|
||
|
|
return 'Request failed';
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
String toString() => toUserMessage();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Properties:**
|
||
|
|
- `status`: HTTP status code (int)
|
||
|
|
- `title`: Error title (str)
|
||
|
|
- `detail`: Human-readable detail (str)
|
||
|
|
- `code`: Machine-readable error code (str?, `UPPER_SNAKE_CASE`)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Error Parsing
|
||
|
|
|
||
|
|
### RFC 7807 Response Format
|
||
|
|
|
||
|
|
**Backend returns `application/problem+json`:**
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"code": "AGENT_FORBIDDEN",
|
||
|
|
"detail": "Forbidden",
|
||
|
|
"params": {
|
||
|
|
"resource": "agent_session",
|
||
|
|
"action": "update"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Frontend parsing:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// core/network/api_problem_mapper.dart
|
||
|
|
class ApiProblemMapper {
|
||
|
|
static ApiProblem? tryParse(Response response) {
|
||
|
|
if (response.statusCode < 400) return null;
|
||
|
|
|
||
|
|
try {
|
||
|
|
final json = jsonDecode(response.body);
|
||
|
|
return ApiProblem(
|
||
|
|
status: response.statusCode,
|
||
|
|
title: json['title'] ?? 'Error',
|
||
|
|
detail: json['detail'] ?? 'Request failed',
|
||
|
|
code: json['code'],
|
||
|
|
);
|
||
|
|
} catch (e) {
|
||
|
|
return ApiProblem(
|
||
|
|
status: response.statusCode,
|
||
|
|
title: 'Error',
|
||
|
|
detail: 'Request failed',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error Code Mapping
|
||
|
|
|
||
|
|
**Map error codes to l10n keys:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
String getLocalizedErrorMessage(BuildContext context, ApiProblem problem) {
|
||
|
|
final code = problem.code;
|
||
|
|
|
||
|
|
if (code == null) {
|
||
|
|
return _getStatusGenericMessage(context, problem.status);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Map code to l10n key
|
||
|
|
final l10nKey = _codeToL10nKey[code];
|
||
|
|
if (l10nKey != null) {
|
||
|
|
return AppLocalizations.of(context)!.getString(l10nKey);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback
|
||
|
|
return AppLocalizations.of(context)!.genericErrorMessage;
|
||
|
|
}
|
||
|
|
|
||
|
|
const _codeToL10nKey = {
|
||
|
|
'AGENT_FORBIDDEN': 'error_agent_forbidden',
|
||
|
|
'INVALID_INPUT': 'error_invalid_input',
|
||
|
|
'VALIDATION_ERROR': 'error_validation_failed',
|
||
|
|
// ...
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### Fallback Order
|
||
|
|
|
||
|
|
**Unknown error code handling:**
|
||
|
|
|
||
|
|
```
|
||
|
|
1. code -> l10n key (if code exists)
|
||
|
|
2. status -> status-generic localized message
|
||
|
|
3. safe generic localized message
|
||
|
|
```
|
||
|
|
|
||
|
|
```dart
|
||
|
|
String getStatusGenericMessage(BuildContext context, int status) {
|
||
|
|
switch (status) {
|
||
|
|
case 401:
|
||
|
|
return AppLocalizations.of(context)!.errorUnauthorized;
|
||
|
|
case 403:
|
||
|
|
return AppLocalizations.of(context)!.errorForbidden;
|
||
|
|
case 404:
|
||
|
|
return AppLocalizations.of(context)!.errorNotFound;
|
||
|
|
case 500:
|
||
|
|
return AppLocalizations.of(context)!.errorServerError;
|
||
|
|
default:
|
||
|
|
return AppLocalizations.of(context)!.genericErrorMessage;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Error Handling Patterns
|
||
|
|
|
||
|
|
### API Layer
|
||
|
|
|
||
|
|
**Catch network errors and map to ApiProblem:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// features/auth/data/apis/auth_api.dart
|
||
|
|
class AuthApi {
|
||
|
|
Future<AuthUser> loginWithEmailOtp({
|
||
|
|
required String email,
|
||
|
|
required String otp,
|
||
|
|
}) async {
|
||
|
|
try {
|
||
|
|
final response = await _httpClient.post(
|
||
|
|
'/auth/login',
|
||
|
|
body: {'email': email, 'otp': otp},
|
||
|
|
);
|
||
|
|
|
||
|
|
final problem = ApiProblemMapper.tryParse(response);
|
||
|
|
if (problem != null) {
|
||
|
|
throw problem;
|
||
|
|
}
|
||
|
|
|
||
|
|
return AuthUser.fromJson(jsonDecode(response.body));
|
||
|
|
} on SocketException {
|
||
|
|
throw ApiProblem(
|
||
|
|
status: 0,
|
||
|
|
title: 'Network Error',
|
||
|
|
detail: 'No internet connection',
|
||
|
|
);
|
||
|
|
} on TimeoutException {
|
||
|
|
throw ApiProblem(
|
||
|
|
status: 0,
|
||
|
|
title: 'Timeout',
|
||
|
|
detail: 'Request timed out',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Repository Layer
|
||
|
|
|
||
|
|
**Propagate errors with context:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// features/auth/data/repositories/auth_repository.dart
|
||
|
|
class AuthRepositoryImpl implements AuthRepository {
|
||
|
|
final AuthApi _api;
|
||
|
|
final SessionStore _sessionStore;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<AuthUser> loginWithEmailOtp({
|
||
|
|
required String email,
|
||
|
|
required String otp,
|
||
|
|
}) async {
|
||
|
|
try {
|
||
|
|
final user = await _api.loginWithEmailOtp(email: email, otp: otp);
|
||
|
|
await _sessionStore.save(user.session);
|
||
|
|
return user;
|
||
|
|
} on ApiProblem {
|
||
|
|
rethrow;
|
||
|
|
} catch (e, stackTrace) {
|
||
|
|
_logger.error(
|
||
|
|
message: 'Login failed',
|
||
|
|
error: e,
|
||
|
|
stackTrace: stackTrace,
|
||
|
|
);
|
||
|
|
throw ApiProblem(
|
||
|
|
status: 500,
|
||
|
|
title: 'Error',
|
||
|
|
detail: 'Request failed, please try again',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### BLoC/State Layer
|
||
|
|
|
||
|
|
**Handle and log errors:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// features/auth/presentation/bloc/auth_bloc.dart
|
||
|
|
Future<void> start() async {
|
||
|
|
_state = _state.copyWith(status: AuthStatus.loading);
|
||
|
|
notifyListeners();
|
||
|
|
|
||
|
|
try {
|
||
|
|
final user = await _repository.recoverSession();
|
||
|
|
if (user == null) {
|
||
|
|
_state = const AuthState(status: AuthStatus.unauthenticated);
|
||
|
|
} else {
|
||
|
|
_state = AuthState(status: AuthStatus.authenticated, user: user);
|
||
|
|
}
|
||
|
|
notifyListeners();
|
||
|
|
} on ApiProblem catch (problem) {
|
||
|
|
_logger.error(
|
||
|
|
message: 'Session recovery failed',
|
||
|
|
error: problem,
|
||
|
|
stackTrace: StackTrace.current,
|
||
|
|
);
|
||
|
|
await _repository.clearLocalSession();
|
||
|
|
_state = AuthState(
|
||
|
|
status: AuthStatus.unauthenticated,
|
||
|
|
errorMessage: problem.toUserMessage(),
|
||
|
|
);
|
||
|
|
notifyListeners();
|
||
|
|
} catch (e, stackTrace) {
|
||
|
|
_logger.error(
|
||
|
|
message: 'Session recovery failed: ${e.runtimeType}',
|
||
|
|
error: e,
|
||
|
|
stackTrace: stackTrace,
|
||
|
|
);
|
||
|
|
await _repository.clearLocalSession();
|
||
|
|
_state = AuthState(
|
||
|
|
status: AuthStatus.unauthenticated,
|
||
|
|
errorMessage: 'Request failed, please try again',
|
||
|
|
);
|
||
|
|
notifyListeners();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### UI Layer
|
||
|
|
|
||
|
|
**Display errors with Toast/Banner:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// features/auth/presentation/screens/login_screen.dart
|
||
|
|
class LoginScreen extends StatelessWidget {
|
||
|
|
Future<void> _handleLogin() async {
|
||
|
|
try {
|
||
|
|
await authBloc.loginWithOtp(email: email, otp: otp);
|
||
|
|
// Success - navigate or show success
|
||
|
|
} on ApiProblem catch (problem) {
|
||
|
|
Toast.show(
|
||
|
|
context: context,
|
||
|
|
message: problem.toUserMessage(),
|
||
|
|
type: ToastType.error,
|
||
|
|
);
|
||
|
|
} catch (e) {
|
||
|
|
Toast.show(
|
||
|
|
context: context,
|
||
|
|
message: 'Request failed, please try again',
|
||
|
|
type: ToastType.error,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Global Error Handling
|
||
|
|
|
||
|
|
### 401 Session Invalidation
|
||
|
|
|
||
|
|
**AuthBloc handles global 401 callback:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// features/auth/presentation/bloc/auth_bloc.dart
|
||
|
|
class AuthBloc extends ChangeNotifier {
|
||
|
|
bool _handlingUnauthorized = false;
|
||
|
|
|
||
|
|
Future<void> handleUnauthorized401() async {
|
||
|
|
if (_handlingUnauthorized) return;
|
||
|
|
_handlingUnauthorized = true;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await _repository.clearLocalSession();
|
||
|
|
_logger.warning(message: 'Session invalidated by 401 callback');
|
||
|
|
_state = const AuthState(status: AuthStatus.unauthenticated);
|
||
|
|
notifyListeners();
|
||
|
|
} finally {
|
||
|
|
_handlingUnauthorized = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**HttpClient intercepts 401:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// core/network/http_client.dart
|
||
|
|
class HttpClient {
|
||
|
|
Future<Response> get(String path) async {
|
||
|
|
final response = await _innerClient.get(path);
|
||
|
|
|
||
|
|
if (response.statusCode == 401) {
|
||
|
|
// Trigger global 401 callback
|
||
|
|
await ServiceLocator.authBloc.handleUnauthorized401();
|
||
|
|
}
|
||
|
|
|
||
|
|
return response;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Common Mistakes
|
||
|
|
|
||
|
|
### ❌ Ignoring Error Codes
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// WRONG: Only using detail text
|
||
|
|
if (response.statusCode == 400) {
|
||
|
|
showError(json['detail']); // Unstable contract
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Right: Use code field:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
final code = json['code'];
|
||
|
|
final message = getLocalizedErrorMessage(context, code);
|
||
|
|
showError(message);
|
||
|
|
```
|
||
|
|
|
||
|
|
### ❌ Nested Try-Catch Without Logging
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// WRONG: Silent failure
|
||
|
|
try {
|
||
|
|
await operation();
|
||
|
|
} catch (e) {
|
||
|
|
// No logging, no re-raise
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Right: Log and propagate:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
try {
|
||
|
|
await operation();
|
||
|
|
} catch (e, stackTrace) {
|
||
|
|
_logger.error(
|
||
|
|
message: 'Operation failed',
|
||
|
|
error: e,
|
||
|
|
stackTrace: stackTrace,
|
||
|
|
);
|
||
|
|
rethrow;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### ❌ Feature-Level Token Clearing
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// WRONG: Feature clearing tokens directly
|
||
|
|
class SomeBloc {
|
||
|
|
Future<void> handleError() async {
|
||
|
|
await tokenStore.clear(); // Wrong!
|
||
|
|
state = ErrorState();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Right: Global callback via AuthBloc:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// Only AuthBloc should clear session
|
||
|
|
// HttpClient triggers AuthBloc.handleUnauthorized401()
|
||
|
|
```
|
||
|
|
|
||
|
|
### ❌ Localized Error from Detail
|
||
|
|
|
||
|
|
```dart
|
||
|
|
// WRONG: Translating free-text detail
|
||
|
|
final message = localize(json['detail']);
|
||
|
|
```
|
||
|
|
|
||
|
|
**Right: Map code to l10n:**
|
||
|
|
|
||
|
|
```dart
|
||
|
|
final code = json['code'];
|
||
|
|
final l10nKey = _codeToL10nKey[code];
|
||
|
|
final message = l10nKey != null
|
||
|
|
? AppLocalizations.of(context)!.getString(l10nKey)
|
||
|
|
: genericMessage;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Error Response Contract
|
||
|
|
|
||
|
|
**Single source of truth: `docs/protocols/common/http-error-codes.md`**
|
||
|
|
|
||
|
|
**Workflow:**
|
||
|
|
|
||
|
|
1. Backend defines new error code → update protocol doc
|
||
|
|
2. Frontend updates code-to-l10n mapping
|
||
|
|
3. Both sides use same `code` field
|
||
|
|
|
||
|
|
**Example protocol update:**
|
||
|
|
|
||
|
|
```markdown
|
||
|
|
## AGENT_FORBIDDEN
|
||
|
|
|
||
|
|
- **Code**: `AGENT_FORBIDDEN`
|
||
|
|
- **Status**: 403
|
||
|
|
- **Detail**: "Forbidden"
|
||
|
|
- **L10n Key**: `error_agent_forbidden`
|
||
|
|
- **Description**: User does not have permission to access agent resource
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Testing Error Handling
|
||
|
|
|
||
|
|
### Unit Testing
|
||
|
|
|
||
|
|
```dart
|
||
|
|
test('ApiProblemMapper parses error response', () {
|
||
|
|
final response = MockResponse(
|
||
|
|
statusCode: 403,
|
||
|
|
body: jsonEncode({
|
||
|
|
'code': 'AGENT_FORBIDDEN',
|
||
|
|
'detail': 'Forbidden',
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
final problem = ApiProblemMapper.tryParse(response);
|
||
|
|
|
||
|
|
expect(problem, isNotNull);
|
||
|
|
expect(problem!.status, 403);
|
||
|
|
expect(problem.code, 'AGENT_FORBIDDEN');
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Widget Testing
|
||
|
|
|
||
|
|
```dart
|
||
|
|
testWidgets('Login screen shows error on failed login', (tester) async {
|
||
|
|
await tester.pumpWidget(MyApp());
|
||
|
|
|
||
|
|
// Trigger error
|
||
|
|
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
|
||
|
|
await tester.tap(find.byKey(Key('login_button')));
|
||
|
|
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
|
||
|
|
expect(find.text('Request failed, please try again'), findsOneWidget);
|
||
|
|
});
|
||
|
|
```
|