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