Error Handling #
Error handling yang baik adalah pembeda antara aplikasi yang terasa profesional dan aplikasi yang frustasi. Pengguna tidak peduli dengan detail teknis seperti ‘DioException type connectionTimeout’ — mereka butuh pesan yang jelas, opsi untuk mencoba lagi, dan tampilan yang tidak blank atau crash. Artikel ini membahas cara membangun sistem error handling yang komprehensif dari layer network hingga UI.
Kategori Error Networking #
ERROR YANG MUNGKIN TERJADI:
1. Network Error -- tidak ada koneksi internet
Contoh: airplane mode, WiFi terputus
2. Timeout -- server terlalu lama merespons
Contoh: server sibuk, koneksi lambat
3. HTTP Error 4xx -- kesalahan dari sisi client
400 Bad Request -- request tidak valid
401 Unauthorized -- perlu login
403 Forbidden -- tidak punya izin
404 Not Found -- resource tidak ada
422 Unprocessable -- gagal validasi
4. HTTP Error 5xx -- kesalahan dari sisi server
500 Internal Server Error
503 Service Unavailable
5. Parsing Error -- response tidak bisa di-parse
Contoh: server kirim HTML saat kita ekspektasi JSON
6. Cancellation -- request dibatalkan oleh user
Contoh: navigasi pergi saat request masih berjalan
Exception Hierarchy #
Definisikan hierarki exception yang merepresentasikan semua kemungkinan error:
// core/errors/exceptions.dart
/// Base exception untuk semua error networking
abstract class AppException implements Exception {
final String message;
final String? details;
const AppException(this.message, {this.details});
@override
String toString() => 'AppException: $message${details != null ? ' ($details)' : ''}';
}
/// Tidak ada koneksi internet
class NetworkException extends AppException {
const NetworkException([String message = 'Tidak ada koneksi internet'])
: super(message);
}
/// Koneksi timeout
class TimeoutException extends AppException {
const TimeoutException([String message = 'Koneksi timeout. Coba lagi.'])
: super(message);
}
/// Error HTTP 4xx dari server
class HttpException extends AppException {
final int statusCode;
const HttpException(String message, this.statusCode, {String? details})
: super(message, details: details);
}
/// 401 Unauthorized
class UnauthorizedException extends HttpException {
const UnauthorizedException([String message = 'Sesi habis. Silakan login kembali.'])
: super(message, 401);
}
/// 403 Forbidden
class ForbiddenException extends HttpException {
const ForbiddenException([String message = 'Kamu tidak memiliki akses.'])
: super(message, 403);
}
/// 404 Not Found
class NotFoundException extends HttpException {
const NotFoundException([String message = 'Data tidak ditemukan.'])
: super(message, 404);
}
/// 422 Validation Error
class ValidationException extends HttpException {
final Map<String, List<String>>? fieldErrors;
const ValidationException({
String message = 'Data tidak valid.',
this.fieldErrors,
}) : super(message, 422);
}
/// 500+ Server Error
class ServerException extends AppException {
const ServerException([String message = 'Server sedang bermasalah. Coba beberapa saat lagi.'])
: super(message);
}
/// Request dibatalkan
class RequestCancelledException extends AppException {
const RequestCancelledException() : super('Request dibatalkan.');
}
/// Response tidak bisa di-parse
class ParseException extends AppException {
const ParseException([String message = 'Format response tidak dikenali.'])
: super(message);
}
Error Interceptor — Handle Error di Satu Tempat #
Error interceptor Dio mengkonversi semua DioException menjadi exception kita yang lebih bermakna:
// core/network/error_interceptor.dart
class ErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final appException = _convertException(err);
// Ganti DioException dengan AppException kita
handler.next(
err.copyWith(
error: appException,
message: appException.message,
),
);
}
AppException _convertException(DioException err) {
switch (err.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const TimeoutException();
case DioExceptionType.connectionError:
return const NetworkException();
case DioExceptionType.cancel:
return const RequestCancelledException();
case DioExceptionType.badResponse:
return _handleHttpError(err.response);
default:
return AppException(err.message ?? 'Terjadi kesalahan tidak diketahui');
}
}
AppException _handleHttpError(Response? response) {
if (response == null) return const ServerException();
final statusCode = response.statusCode ?? 0;
final data = response.data;
// Ambil pesan dari response body jika ada
String message = _extractMessage(data);
switch (statusCode) {
case 400:
return HttpException(message.isNotEmpty ? message : 'Request tidak valid.', 400);
case 401:
return UnauthorizedException(message.isNotEmpty ? message : 'Sesi habis. Silakan login kembali.');
case 403:
return ForbiddenException(message.isNotEmpty ? message : 'Kamu tidak memiliki akses.');
case 404:
return NotFoundException(message.isNotEmpty ? message : 'Data tidak ditemukan.');
case 422:
final fieldErrors = _extractFieldErrors(data);
return ValidationException(
message: message.isNotEmpty ? message : 'Data tidak valid.',
fieldErrors: fieldErrors,
);
case >= 500:
return ServerException(message.isNotEmpty ? message : 'Server sedang bermasalah.');
default:
return HttpException('Error: $statusCode', statusCode);
}
}
String _extractMessage(dynamic data) {
if (data is Map) {
return (data['message'] ?? data['error'] ?? '').toString();
}
return '';
}
Map<String, List<String>>? _extractFieldErrors(dynamic data) {
if (data is! Map) return null;
final errors = data['errors'];
if (errors is! Map) return null;
return errors.map(
(key, value) => MapEntry(
key.toString(),
(value as List).map((e) => e.toString()).toList(),
),
);
}
}
Either Pattern — Return Error Tanpa Throw #
Pattern Either<Left, Right> mengembalikan hasil yang bisa berupa error (Left) atau sukses (Right) — tanpa perlu try-catch di setiap pemanggil:
# pubspec.yaml
# dependencies:
# fpdart: ^1.1.0
import 'package:fpdart/fpdart.dart';
// Repository menggunakan Either
abstract class ProdukRepository {
Future<Either<AppException, List<Produk>>> getProduk();
Future<Either<AppException, Produk>> getProdukById(String id);
}
class ProdukRepositoryImpl implements ProdukRepository {
final ProdukRemoteDataSource _remote;
ProdukRepositoryImpl(this._remote);
@override
Future<Either<AppException, List<Produk>>> getProduk() async {
try {
final dtos = await _remote.getProduk();
return Right(dtos.map((d) => d.toDomain()).toList());
} on AppException catch (e) {
return Left(e); // kembalikan error tanpa throw
} catch (e) {
return Left(AppException(e.toString()));
}
}
@override
Future<Either<AppException, Produk>> getProdukById(String id) async {
try {
final dto = await _remote.getProdukById(id);
return Right(dto.toDomain());
} on AppException catch (e) {
return Left(e);
} catch (e) {
return Left(AppException(e.toString()));
}
}
}
// Penggunaan di Notifier
class ProdukNotifier extends AsyncNotifier<List<Produk>> {
@override
Future<List<Produk>> build() async {
final result = await ref.watch(produkRepositoryProvider).getProduk();
return result.fold(
(error) => throw error, // konversi Left ke exception untuk AsyncValue
(produk) => produk, // ekstrak Right
);
}
}
// Atau gunakan langsung di widget
final result = await repository.getProduk();
result.fold(
(error) => showErrorSnackbar(error.message),
(produk) => setState(() => _produk = produk),
);
Retry Logic #
Retry otomatis untuk error sementara (timeout, network error):
// Menggunakan dio_smart_retry atau implementasi sendiri
class RetryInterceptor extends Interceptor {
final Dio dio;
final int maxRetries;
final Duration retryDelay;
RetryInterceptor({
required this.dio,
this.maxRetries = 3,
this.retryDelay = const Duration(seconds: 1),
});
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (_shouldRetry(err) && err.requestOptions.extra['retryCount'] == null) {
await _retry(err, handler);
} else {
handler.next(err);
}
}
bool _shouldRetry(DioException err) {
return err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.connectionError ||
(err.response?.statusCode == 503);
}
Future<void> _retry(DioException err, ErrorInterceptorHandler handler) async {
final options = err.requestOptions;
int retryCount = 0;
while (retryCount < maxRetries) {
retryCount++;
await Future.delayed(retryDelay * retryCount); // exponential backoff
try {
final response = await dio.fetch(
options.copyWith(extra: {...options.extra, 'retryCount': retryCount}),
);
handler.resolve(response);
return;
} on DioException catch (e) {
if (retryCount >= maxRetries || !_shouldRetry(e)) {
handler.next(e);
return;
}
}
}
}
}
Error Handling di UI #
Widget Error yang Reusable #
class ErrorView extends StatelessWidget {
final String pesan;
final String? details;
final VoidCallback? onRetry;
final IconData icon;
const ErrorView({
super.key,
required this.pesan,
this.details,
this.onRetry,
this.icon = Icons.error_outline,
});
factory ErrorView.fromException(AppException e, {VoidCallback? onRetry}) {
return ErrorView(
pesan: e.message,
details: e.details,
onRetry: onRetry,
icon: e is NetworkException ? Icons.wifi_off : Icons.error_outline,
);
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text(
pesan,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (details != null) ...[
const SizedBox(height: 8),
Text(
details!,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
if (onRetry != null) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
),
],
],
),
),
);
}
}
Menampilkan Error dari AsyncValue (Riverpod) #
class ProdukScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final produkAsync = ref.watch(produkProvider);
return produkAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) {
final appError = error is AppException
? error
: AppException(error.toString());
return ErrorView.fromException(
appError,
onRetry: () => ref.invalidate(produkProvider),
);
},
data: (produk) => ProdukList(produk: produk),
);
}
}
Menampilkan Validasi Error per Field #
class LoginForm extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(loginProvider);
final fieldErrors = state.error is ValidationException
? (state.error as ValidationException).fieldErrors
: null;
return Column(
children: [
TextFormField(
decoration: InputDecoration(
labelText: 'Email',
errorText: fieldErrors?['email']?.first,
),
),
TextFormField(
decoration: InputDecoration(
labelText: 'Password',
errorText: fieldErrors?['password']?.first,
),
obscureText: true,
),
if (state.hasError && state.error is! ValidationException)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
(state.error as AppException).message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}
}
Global Error Handler #
Untuk error yang perlu ditangani secara global (misal: 401 yang memaksa logout):
// Di root widget
class App extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Listen ke auth state untuk handle 401 global
ref.listen<AsyncValue<AuthState>>(authProvider, (_, state) {
state.whenData((auth) {
if (auth is AuthUnauthenticated) {
// Navigasi ke login dari mana pun
rootNavigatorKey.currentContext?.go('/login');
}
});
});
return MaterialApp.router(routerConfig: router);
}
}
// Di Auth interceptor -- trigger saat terima 401
class AuthInterceptor extends Interceptor {
final Ref ref;
AuthInterceptor(this.ref);
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
ref.read(authProvider.notifier).logout(); // trigger global logout
}
handler.next(err);
}
}
Ringkasan #
- Definisikan hierarki exception yang mencerminkan semua jenis error networking —
NetworkException,TimeoutException,UnauthorizedException,ValidationException, dll. Ini membuat penanganan lebih spesifik dan pesan error lebih bermakna bagi pengguna.- Gunakan Error Interceptor di Dio untuk mengkonversi
DioExceptionmenjadi exception kita di satu tempat — tidak perlu try-catch di setiap call site.- Either pattern (
Either<AppException, T>) memungkinkan repository mengembalikan error atau sukses tanpa throw — caller bisa menangani keduanya denganfold()yang eksplisit.- Retry logic dengan exponential backoff untuk error sementara (timeout, network error, 503) — jangan retry untuk 4xx yang memang kesalahan client.
- Buat widget
ErrorViewyang reusable dengan tombol retry — jangan biarkan layar blank atau hanya menampilkan pesan teknis.- Validasi error (422) perlu ditampilkan per field, bukan hanya sebagai satu pesan global — parse
fieldErrorsdari response dan tampilkan di field yang relevan.- Global error handler untuk 401 yang memaksa logout dari mana pun — gunakan interceptor yang memicu state management, bukan navigasi langsung.
← Sebelumnya: Repository Pattern Berikutnya: Authentication →