Error Handling #
Di dalam pengembangan aplikasi seluler profesional, cara kita menangani kesalahan (error handling) adalah salah satu pembeda utama antara aplikasi amatir dengan aplikasi tingkat produksi yang sukses. Ketika terjadi gangguan jaringan, pengguna akhir tidak peduli dengan pesan teknis berbelit-belit seperti DioException [connection error]: SocketException: Connection refused. Pengguna hanya menginginkan informasi yang jelas tentang apa yang salah, opsi untuk mencoba kembali secara mudah, serta jaminan bahwa aplikasi tidak akan tiba-tiba mengalami force close (crash) atau memunculkan layar putih kosong.
Mengelola error jaringan dengan benar menuntut kita untuk membangun sistem penanganan kesalahan yang komprehensif di setiap lapisan arsitektur. Kita harus menangkap kesalahan teknis di tingkat terbawah (layer HTTP Client), memetakannya menjadi objek pengecualian yang bermakna di layer data, mengalirkannya secara deklaratif di layer bisnis, hingga merendernya secara ramah pengguna di layer antarmuka (UI).
Dalam panduan ini, kita akan membahas strategi penanganan kesalahan jaringan di Flutter secara mendalam. Kita akan mempelajari pengelompokan kesalahan, merancang hierarki exception kustom, mengotomatiskan pemetaan error via interceptor, menerapkan Either Pattern dari pemrograman fungsional, hingga merancang widget UI yang tangguh dan bersahabat.
Kategori Kesalahan Jaringan (Networking Errors) #
Sebelum kita mulai menulis kode penanganan error, kita perlu mengidentifikasi dan mengelompokkan berbagai jenis kesalahan yang berpotensi terjadi selama komunikasi jaringan berlangsung:
- Gangguan Koneksi Jaringan (Network Offline): Terjadi ketika perangkat pengguna kehilangan koneksi internet fisik sama sekali (misal: pengguna sedang berada di dalam terowongan, mengaktifkan mode pesawat, atau kuota data habis).
- Waktu Habis (Timeout): Terjadi ketika koneksi fisik terjalin namun server backend terlalu lambat merespons karena beban kerja yang berlebihan (overload) atau sinyal internet pengguna sangat lambat.
- Kesalahan Klien (HTTP 4xx): Terjadi akibat kesalahan pengiriman parameter dari aplikasi kita ke server (misal: salah memasukkan email/password (400/422), token autentikasi kedaluwarsa (401), tidak memiliki izin akses (403), atau data yang dicari sudah dihapus (404)).
- Kesalahan Server (HTTP 5xx): Terjadi akibat gangguan internal di dalam kode server backend kita (seperti kegagalan koneksi database server (500) atau server sedang mati untuk pemeliharaan (503)).
- Kegagalan Parsing Data (Data Parsing Error): Terjadi ketika server berhasil membalas dengan status 200 OK, namun format isi data (response body) yang dikirimkan berubah di luar kesepakatan (misal: server mengirimkan dokumen HTML mentah berisi pesan error padahal aplikasi kita mengekspektasikan format JSON).
- Pembatalan Permintaan (Cancellation): Terjadi ketika request dihentikan secara sengaja oleh aplikasi (misal: pengguna meninggalkan halaman sebelum proses mengunduh selesai).
Mendesain Hierarki Exception Kustom #
Dart menyediakan kelas bawaan Exception, namun kelas tersebut terlalu umum untuk membedakan berbagai jenis kesalahan jaringan di atas. Kita harus mendesain hierarki kelas pengecualian kustom kita sendiri berbasis AppException agar kita dapat menangani setiap jenis kesalahan dengan penanganan visual yang berbeda di UI.
Berikut adalah susunan kelas hierarki exception kustom yang direkomendasikan untuk proyek Flutter kita:
// core/errors/exceptions.dart
/// Kelas basis untuk semua pengecualian di aplikasi kita
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)' : ''}';
}
/// Kesalahan akibat tidak ada koneksi internet fisik
class NetworkException extends AppException {
const NetworkException([String message = 'Tidak ada koneksi internet. Cek kembali Wi-Fi atau paket data kita.'])
: super(message);
}
/// Kesalahan akibat batas waktu request habis
class TimeoutException extends AppException {
const TimeoutException([String message = 'Koneksi waktu habis. Mohon coba kembali beberapa saat lagi.'])
: super(message);
}
/// Kelas basis untuk semua kesalahan status code HTTP (4xx & 5xx)
class HttpException extends AppException {
final int statusCode;
const HttpException(String message, this.statusCode, {String? details})
: super(message, details: details);
}
/// HTTP 401 - Akses tidak sah (Unauthorized)
class UnauthorizedException extends HttpException {
const UnauthorizedException([String message = 'Sesi masuk kita telah berakhir. Silakan masuk kembali.'])
: super(message, 401);
}
/// HTTP 403 - Akses ditolak (Forbidden)
class ForbiddenException extends HttpException {
const ForbiddenException([String message = 'Kita tidak memiliki izin untuk mengakses bagian ini.'])
: super(message, 403);
}
/// HTTP 404 - Data tidak ditemukan (Not Found)
class NotFoundException extends HttpException {
const NotFoundException([String message = 'Data yang kita cari tidak ditemukan di server.'])
: super(message, 404);
}
/// HTTP 422 - Kegagalan Validasi Input Form (Validation Error)
class ValidationException extends HttpException {
// Menyimpan peta pesan kesalahan untuk masing-masing field input
final Map<String, List<String>>? fieldErrors;
const ValidationException({
String message = 'Data yang dimasukkan tidak valid.',
this.fieldErrors,
}) : super(message, 422);
}
/// HTTP 5xx - Kesalahan Internal Server
class ServerException extends AppException {
const ServerException([String message = 'Server kami sedang mengalami gangguan internal. Mohon coba lagi nanti.'])
: super(message);
}
/// Kesalahan akibat pembatalan request secara sengaja
class RequestCancelledException extends AppException {
const RequestCancelledException([String message = 'Permintaan dibatalkan oleh sistem.'])
: super(message);
}
/// Kesalahan akibat kegagalan parsing format JSON
class ParseException extends AppException {
const ParseException([String message = 'Gagal mengurai format data dari server.'])
: super(message);
}
Error Interceptor: Konversi Terpusat dengan Dio #
Setelah mendesain kelas pengecualian kustom, tantangan berikutnya adalah: bagaimana cara mengonversi DioException yang dilemparkan oleh Dio secara otomatis menjadi AppException kita? Menuliskan blok try-catch di setiap kelas API untuk menerjemahkan exception sangatlah tidak praktis.
Cara terbaik adalah membuat Error Interceptor kustom yang bertugas mencegat semua error di tingkat terbawah secara terpusat, memetakan tipenya, lalu mengalirkan AppException ke layer di atasnya.
Berikut adalah diagram bagaimana ErrorInterceptor bertindak sebagai pos penyaringan kesalahan di dalam aplikasi kita:
graph TD
classDef default stroke:#333,stroke-width:2px;
A["Terjadi DioException Jaringan"] --> B{"Tipe DioException?"}
B -->|Timeout| C["TimeoutException"]
B -->|Cancel| D["RequestCancelledException"]
B -->|ConnectionError| E["NetworkException"]
B -->|BadResponse| F{"Status Code HTTP?"}
F -->|401| G["UnauthorizedException (Memicu Global Logout)"]
F -->|403| H["ForbiddenException"]
F -->|404| I["NotFoundException"]
F -->|422| J["ValidationException (Mengandung Map Field Errors)"]
F -->|5xx| K["ServerException"]
F -->|Lainnya| L["HttpException Umum"]
C & D & E & G & H & I & J & K & L --> M["Dipetakan ke AppException & Diteruskan ke UI"]Implementasi Kode ErrorInterceptor #
// core/network/error_interceptor.dart
import 'package:dio/dio.dart';
import '../errors/exceptions.dart';
class ErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 1. Konversikan DioException menjadi AppException kustom kita
final appException = _petakanException(err);
// 2. Teruskan error baru dengan membungkusnya ke dalam objek DioException.copyWith
handler.next(
err.copyWith(
error: appException,
message: appException.message,
),
);
}
AppException _petakanException(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:
// Dipicu ketika server membalas dengan status 4xx atau 5xx
return _uraiHttpError(err.response);
default:
return AppException('Terjadi gangguan jaringan tidak dikenal: ${err.message}');
}
}
AppException _uraiHttpError(Response? response) {
if (response == null) return const ServerException();
final statusCode = response.statusCode ?? 0;
final dataBody = response.data;
// Ekstrak pesan kesalahan yang dikirimkan oleh server backend
final String pesanServer = _ekstrakPesanDariResponseBody(dataBody);
switch (statusCode) {
case 400:
return HttpException(pesanServer.isNotEmpty ? pesanServer : 'Permintaan tidak valid.', 400);
case 401:
return UnauthorizedException(pesanServer.isNotEmpty ? pesanServer : 'Sesi masuk berakhir.');
case 403:
return ForbiddenException(pesanServer.isNotEmpty ? pesanServer : 'Akses ditolak.');
case 404:
return NotFoundException(pesanServer.isNotEmpty ? pesanServer : 'Data tidak ditemukan.');
case 422:
// Mengurai error validasi form spesifik per field
final fieldErrors = _ekstrakDetailValidasi(dataBody);
return ValidationException(
message: pesanServer.isNotEmpty ? pesanServer : 'Data formulir tidak valid.',
fieldErrors: fieldErrors,
);
case >= 500:
return ServerException(pesanServer.isNotEmpty ? pesanServer : 'Server sedang bermasalah.');
default:
return HttpException('Kesalahan jaringan: $statusCode', statusCode);
}
}
String _ekstrakPesanDariResponseBody(dynamic data) {
if (data is Map) {
// Menyesuaikan struktur JSON response error server backend kita
return (data['message'] ?? data['error'] ?? '').toString();
}
return '';
}
Map<String, List<String>>? _ekstrakDetailValidasi(dynamic data) {
if (data is! Map) return null;
final errors = data['errors']; // Struktur standar error Laravel/Rails: {"errors": {"email": ["format salah"]}}
if (errors is! Map) return null;
return errors.map(
(key, value) => MapEntry(
key.toString(),
(value as List).map((item) => item.toString()).toList(),
),
);
}
}
Either Pattern: Menangani Error Secara Deklaratif #
Setelah pengecualian dipetakan secara terpusat oleh interceptor, bagaimana cara mengalirkannya ke layer logika bisnis? Kebiasaan melemparkan exception secara langsung menggunakan kata kunci throw menuntut kita untuk selalu membungkus setiap pemanggilan fungsi dengan blok try-catch di tingkat controller. Jika kita lupa membungkusnya, aplikasi kita berisiko mengalami crash runtime.
Untuk menghindari penggunaan try-catch yang berulang-ulang, arsitektur modern menerapkan Either Pattern dari paradigma pemrograman fungsional. Menggunakan paket fpdart, fungsi akan mengembalikan satu objek berjenis Either<L, R>:
- Left (
L): Menyimpan objek kesalahan (AppException) jika proses gagal. - Right (
R): Menyimpan objek data sukses (T) jika proses berhasil.
Tambahkan dependensi fpdart di berkas pubspec.yaml:
dependencies:
fpdart: ^1.1.0
Implementasi Either Pattern di Repository #
Mari kita lihat bagaimana Either digunakan secara bersih di layer Repository aplikasi kita:
import 'package:fpdart/fpdart.dart';
import '../errors/exceptions.dart';
import '../entities/produk.dart';
abstract class ProdukRepository {
// Fungsi mengembalikan Either: Left berupa AppException, Right berupa List produk
Future<Either<AppException, List<Produk>>> dapatkanDaftarProduk();
}
class ProdukRepositoryImpl implements ProdukRepository {
final ProdukRemoteDataSource _remote;
ProdukRepositoryImpl(this._remote);
@override
Future<Either<AppException, List<Produk>>> dapatkanDaftarProduk() async {
try {
final dtos = await _remote.getProduk();
final listEntity = dtos.map((dto) => dto.toDomain()).toList();
// Kembalikan status sukses dibungkus dalam Right
return Right(listEntity);
} on DioException catch (e) {
// Menangkap error yang sudah dikonversi oleh ErrorInterceptor kita
final appException = e.error is AppException
? e.error as AppException
: AppException(e.message ?? 'Kesalahan tidak dikenal');
// Kembalikan status error dibungkus dalam Left
return Left(appException);
} catch (e) {
return Left(AppException('Gagal memproses data: $e'));
}
}
}
Mengonsumsi Either di Layer State Management (Notifier) #
Untuk mengolah hasil Either, kita menggunakan metode fold() yang memaksa kita menangani kedua sisi (kiri dan kanan) secara eksplisit:
class ProdukNotifier extends AutoDisposeAsyncNotifier<List<Produk>> {
@override
Future<List<Produk>> build() async {
final repository = ref.watch(produkRepositoryProvider);
final hasilEither = await repository.dapatkanDaftarProduk();
return hasilEither.fold(
// Sisi Left: Konversikan menjadi throw agar dibaca oleh AsyncValue.error di UI
(appException) => throw appException,
// Sisi Right: Kembalikan data sukses
(listProduk) => listProduk,
);
}
}
Logika Percobaan Ulang (Retry Logic) dengan Backoff Eksponensial #
Terkadang kesalahan jaringan hanya bersifat sementara (transient error), seperti hilangnya sinyal Wi-Fi selama 1 detik atau server backend yang overload sesaat. Daripada langsung menampilkan layar error ke pengguna, alangkah baiknya jika aplikasi kita mencoba mengirim ulang request tersebut secara otomatis di latar belakang.
Kita dapat membuat Retry Interceptor kustom dengan teknik Exponential Backoff. Jeda waktu tunggu antar percobaan akan meningkat secara eksponensial (misal: coba 1 setelah 1 detik, coba 2 setelah 2 detik, coba 3 setelah 4 detik) untuk menghindari membebani server backend kita yang sedang sibuk.
class RetryInterceptor extends Interceptor {
final Dio dio;
final int maxPercobaan;
final Duration jedaAwal;
RetryInterceptor({
required this.dio,
this.maxPercobaan = 3,
this.jedaAwal = const Duration(seconds: 1),
});
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final options = err.requestOptions;
// Cek status percobaan saat ini dari Map request options extra
final percobaanSaatIni = options.extra['retry_count'] as int? ?? 0;
// Kita hanya mengulangi request untuk error koneksi sementara
if (_apakahErrorSementara(err) && percobaanSaatIni < maxPercobaan) {
final percobaanBerikutnya = percobaanSaatIni + 1;
// Hitung jeda waktu dengan teknik exponential backoff (jeda * 2^percobaan)
final durasiJeda = jedaAwal * (percobaanBerikutnya * 2);
print('Koneksi gagal. Melakukan percobaan ulang ke-$percobaanBerikutnya setelah ${durasiJeda.inSeconds} detik...');
await Future.delayed(durasiJeda);
try {
// Lakukan pengiriman ulang request dengan menyertakan index percobaan baru
final response = await dio.fetch(
options.copyWith(
extra: {
...options.extra,
'retry_count': percobaanBerikutnya,
},
),
);
// Jika sukses pada percobaan ulang, selesaikan dengan response sukses
handler.resolve(response);
return;
} on DioException catch (e) {
// Jika gagal kembali, biarkan loop on_error berikutnya yang menangani
err = e;
}
}
// Jika sudah melebihi batas percobaan atau bukan error sementara, teruskan error ke atas
handler.next(err);
}
bool _apakahErrorSementara(DioException err) {
return err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.connectionError ||
// Status 503 Service Unavailable layak untuk dicoba ulang
(err.response?.statusCode == 503);
}
}
Mendesain Antarmuka Pengguna (UI) Tangguh terhadap Error #
Di layar UI, kita harus menyediakan umpan balik visual yang informatif bagi pengguna. Sangat direkomendasikan untuk membuat satu widget tampilan kesalahan yang dapat digunakan kembali (reusable widget) bernama ErrorView.
Widget ini akan otomatis menyesuaikan ikonnya berdasarkan jenis kesalahan (misal: gambar Wi-Fi mati jika offline) dan menyediakan tombol untuk mencoba kembali (retry).
Implementasi Widget ErrorView #
// presentation/widgets/error_view.dart
import 'package:flutter/material.dart';
import '../../core/errors/exceptions.dart';
class ErrorView extends StatelessWidget {
final String judulError;
final String? deskripsiDetail;
final IconData ikonVisual;
final VoidCallback? aksiCobaLagi;
const ErrorView({
super.key,
required this.judulError,
this.deskripsiDetail,
this.ikonVisual = Icons.error_outline_rounded,
this.aksiCobaLagi,
});
// Factory constructor untuk memudahkan inisialisasi langsung dari AppException kita
factory ErrorView.dariException(AppException exception, {VoidCallback? cobaLagi}) {
return ErrorView(
judulError: exception.message,
deskripsiDetail: exception.details,
ikonVisual: exception is NetworkException
? Icons.wifi_off_rounded
: exception is TimeoutException
? Icons.timer_off_outlined
: Icons.error_outline_rounded,
aksiCobaLagi: cobaLagi,
);
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(ikonVisual, size: 72, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 20),
Text(
judulError,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (deskripsiDetail != null) ...[
const SizedBox(height: 8),
Text(
deskripsiDetail!,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
if (aksiCobaLagi != null) ...[
const SizedBox(height: 28),
ElevatedButton.icon(
onPressed: aksiCobaLagi,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Coba Lagi'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
],
),
),
);
}
}
Validasi Formulir Dinamis Berdasarkan Kesalahan API #
Ketika server backend menolak request pendaftaran kita karena data input tidak valid (HTTP 422), server biasanya mengembalikan pesan error detail untuk masing-masing field (misal: “email sudah terdaftar”, “password terlalu pendek”).
Kita harus menangkap ValidationException ini dan menampilkan teks kesalahan tersebut tepat di bawah masing-masing widget input form yang relevan secara dinamis.
class LayarPendaftaran extends ConsumerStatefulWidget {
const LayarPendaftaran({super.key});
@override
ConsumerState<LayarPendaftaran> createState() => _LayarPendaftaranState();
}
class _LayarPendaftaranState extends ConsumerState<LayarPendaftaran> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
// Membaca state pendaftaran dari notifier
final stateDaftar = ref.watch(pendaftaranProvider);
// Cek apakah error berjenis ValidationException
final fieldErrors = stateDaftar.error is ValidationException
? (stateDaftar.error as ValidationException).fieldErrors
: null;
return Scaffold(
appBar: AppBar(title: const Text('Pendaftaran Akun')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
// Tampilkan pesan error spesifik field email dari API
errorText: fieldErrors?['email']?.first,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
// Tampilkan pesan error spesifik field password dari API
errorText: fieldErrors?['password']?.first,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: stateDaftar.isLoading
? null
: () {
ref.read(pendaftaranProvider.notifier).daftar(
_emailController.text,
_passwordController.text,
);
},
child: const Text('Daftar'),
),
],
),
),
);
}
}
Penanganan Error Global (Global Error Handling) #
Ada beberapa jenis kesalahan jaringan yang penanganannya harus dilakukan secara global di luar layar UI saat itu. Contoh klasik adalah kesalahan status code HTTP 401 Unauthorized.
Jika server mengembalikan status 401 di endpoint mana pun (karena token kedaluwarsa atau dihapus admin), aplikasi harus langsung menghentikan aktivitas saat itu, menghapus token dari memori penyimpanan lokal aman, dan mengarahkan paksa pengguna kembali ke halaman login.
Kita mengimplementasikan alur logout paksa ini secara global menggunakan koordinasi antara Interceptor Dio dengan state listener router aplikasi kita:
// 1. Inisialisasi Kunci Navigator Global untuk Navigasi Tanpa BuildContext
final rootNavigatorKey = GlobalKey<NavigatorState>();
class AuthErrorInterceptor extends Interceptor {
final ProviderContainer _container;
AuthErrorInterceptor(this._container);
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
print('Terjadi error 401 global. Memaksa pengguna logout.');
// Memicu aksi logout paksa pada State Management secara terpusat
_container.read(authNotifierProvider.notifier).logoutPaksa();
}
super.onError(err, handler);
}
}
// 2. Hubungkan status autentikasi di tingkat MaterialApp root widget kita
class AplikasiUtama extends ConsumerWidget {
const AplikasiUtama({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Memantau perubahan status autentikasi secara global
ref.listen<AuthState>(authNotifierProvider, (previousState, currentState) {
if (currentState is AuthStateUnauthenticated) {
// Arahkan paksa pengguna ke layar login menggunakan Navigator Key global
rootNavigatorKey.currentState?.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LayarLogin()),
(route) => false,
);
}
});
return MaterialApp(
navigatorKey: rootNavigatorKey, // Daftarkan Navigator Key global
home: const LayarBeranda(),
);
}
}
Dengan alur arsitektur seperti ini, penanganan token expired diurus secara terpusat di latar belakang tanpa mengotori berkas logika visual halaman kita masing-masing.
Ringkasan #
- Desain Hierarki Exception Kustom berbasis
AppExceptionmempermudah pengelompokan kesalahan jaringan (Timeout, Offline, Server Error) agar UI dapat menampilkan umpan balik yang tepat.- Error Interceptor bertugas menangkap pengecualian teknis
DioExceptiondan menerjemahkannya secara otomatis menjadi kelas pengecualian kustom kita secara terpusat.- Either Pattern (
Either<AppException, T>) menyalurkan status error (Left) atau sukses (Right) sebagai nilai balik fungsi biasa, sehingga meniadakan penulisan bloktry-catchdi tingkat controller.- Logika Retry Otomatis dengan Exponential Backoff membantu melakukan pengulangan request untuk kesalahan koneksi sementara guna meningkatkan stabilitas aplikasi.
- Widget
ErrorViewReusable harus selalu menyertakan tombol coba lagi (retry) dan pesan bahasa Indonesia yang mudah dimengerti pengguna awam.- Penguraian Error Validasi (422) secara dinamis memetakan pesan kegagalan input form langsung ke teks kesalahan masing-masing kolom input yang bersangkutan di UI.
- Global Error Handling mengoordinasikan status kegagalan keamanan (401) di tingkat interceptor untuk melakukan reset sesi masuk dan mengarahkan paksa pengguna ke halaman login dari mana pun.
← Sebelumnya: Repository Pattern Berikutnya: Authentication →