Files
eryao/.trellis/spec/frontend/error-handling.md
T

505 lines
10 KiB
Markdown
Raw Normal View History

# 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);
});
```