10 KiB
10 KiB
Error Handling
How errors are handled in Flutter app.
Overview
This app follows RFC 7807 Problem Details for error handling:
- Error model:
ApiProblemclass - Error parsing:
api_problem_mapper.dartmaps HTTP errors toApiProblem - 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:
// 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:
{
"code": "AGENT_FORBIDDEN",
"detail": "Forbidden",
"params": {
"resource": "agent_session",
"action": "update"
}
}
Frontend parsing:
// 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:
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
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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
// WRONG: Only using detail text
if (response.statusCode == 400) {
showError(json['detail']); // Unstable contract
}
Right: Use code field:
final code = json['code'];
final message = getLocalizedErrorMessage(context, code);
showError(message);
❌ Nested Try-Catch Without Logging
// WRONG: Silent failure
try {
await operation();
} catch (e) {
// No logging, no re-raise
return null;
}
Right: Log and propagate:
try {
await operation();
} catch (e, stackTrace) {
_logger.error(
message: 'Operation failed',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
❌ Feature-Level Token Clearing
// WRONG: Feature clearing tokens directly
class SomeBloc {
Future<void> handleError() async {
await tokenStore.clear(); // Wrong!
state = ErrorState();
}
}
Right: Global callback via AuthBloc:
// Only AuthBloc should clear session
// HttpClient triggers AuthBloc.handleUnauthorized401()
❌ Localized Error from Detail
// WRONG: Translating free-text detail
final message = localize(json['detail']);
Right: Map code to l10n:
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:
- Backend defines new error code → update protocol doc
- Frontend updates code-to-l10n mapping
- Both sides use same
codefield
Example protocol update:
## 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
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
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);
});