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

10 KiB

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:

// 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:

  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:

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