Mengintegrasikan REST API di Flutter dengan Dio: Interceptor, Retry, dan Error Handling

Lhuqita Fazry
Mobile Development Flutter Dio REST API HTTP Client
Mengintegrasikan REST API di Flutter dengan Dio: Interceptor, Retry, dan Error Handling

Dio sebagai HTTP Client Modern untuk Flutter

Setiap aplikasi Flutter yang terhubung ke internet pasti perlu berkomunikasi dengan server backend. Banyak developer memulai dengan paket http bawaan Dart karena sederhana. Tapi seiring aplikasi tumbuh, kita butuh lebih dari sekadar fungsi get() dan post().

Dio hadir sebagai HTTP client yang dirancang untuk aplikasi production-grade. Library ini bukan sekadar pembungkus HttpClient, melainkan menawarkan arsitektur berlapis: Interceptor, Transformer, dan Adapter. Dengan pendekatan ini, setiap request HTTP melewati serangkaian middleware yang bisa kita kontrol penuh.

Apa yang membuat Dio berbeda dari paket http standar? Yang paling menonjol adalah chain interceptor. Dengan http, kita harus menyuntikkan header Authorization secara manual di setiap panggilan API atau membangun wrapper khusus. Dengan Dio, kita cukup daftarkan interceptor satu kali dan semua request otomatis terproses. Setiap interceptor bertindak sebagai middleware mandiri yang bisa ditumpuk, ditukar urutannya, atau dinonaktifkan tanpa memengaruhi kode lain. Dio juga mendukung pembatalan request via CancelToken, upload file dengan FormData, timeout yang terkontrol rapi, dan penanganan error yang jauh lebih granular.

Beberapa fitur yang akan kita gunakan di artikel ini meliputi interceptor pipeline untuk menyisipkan token autentikasi, mekanisme retry otomatis saat request gagal, dan penanganan error terstruktur melalui DioException. Semua ini diatur di satu tempat, bukan tersebar di seluruh kode aplikasi.

Setup Dio Instance dengan Konfigurasi Global

Langkah pertama yang wajib dilakukan adalah membuat instance Dio dengan konfigurasi global. Pendekatan terbaik adalah menggunakan pola singleton, sehingga satu konfigurasi dipakai oleh seluruh bagian aplikasi.

dartdart
import 'package:dio/dio.dart';

class ApiClient {
  late final Dio _dio;

  ApiClient._internal() {
    _dio = Dio(
      BaseOptions(
        baseUrl: 'https://api.example.com',
        connectTimeout: const Duration(seconds: 10),
        receiveTimeout: const Duration(seconds: 10),
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
        },
      ),
    );

    _dio.interceptors.add(
      LogInterceptor(
        requestBody: true,
        responseBody: true,
        logPrint: (log) => print('[DIO] $log'),
      ),
    );
  }

  static final ApiClient _instance = ApiClient._internal();
  factory ApiClient() => _instance;

  Dio get dio => _dio;
}

Perhatikan BaseOptions di atas. Kita menetapkan baseUrl sekali, dan semua panggilan API cukup menggunakan path relatif. connectTimeout dan receiveTimeout melindungi aplikasi dari koneksi yang menggantung tanpa batas. Header default seperti Content-Type dan Accept otomatis disertakan di setiap request, jadi tidak perlu mengulang-ulang.

Kita juga menambahkan LogInterceptor sebagai middleware pertama. Fungsinya untuk mencatat setiap request dan response yang keluar-masuk. Ini sangat membantu saat debugging karena kita bisa melihat header, body, dan status code langsung di console. Interceptor ini idealnya dinonaktifkan di mode production.

Selain itu, konfigurasi BaseOptions bisa disesuaikan dengan environment aplikasi. Saat development, kita bisa menggunakan baseUrl server staging dengan timeout yang lebih longgar. Saat build production, cukup ubah satu baris konfigurasi dan seluruh layer API otomatis mengarah ke server produksi.

Interceptor untuk Autentikasi Token dan Transformasi Request

Interceptor adalah jantung dari fleksibilitas Dio. Setiap request berjalan melalui siklus onRequest, onResponse, dan onError. Kita bisa menyisipkan logic di ketiga fase tersebut.

Diagram arsitektur interceptor chain Dio — setiap request melewati LogInterceptor, AuthInterceptor, dan RetryInterceptor secara berurutan

Gambar: Alur request melalui interceptor chain Dio, dari request masuk hingga response kembali ke aplikasi — Sumber: [Mermaid](https://mermaid.js.org/)

Bayangkan kita perlu mengirim token JWT di setiap request dan menangani kasus token kadaluwarsa. Daripada menulis ulang logika ini di setiap fungsi, kita buat satu AuthInterceptor.

dartdart
class AuthInterceptor extends Interceptor {
  final Dio Function() _dioFactory;
  String? _accessToken;
  String? _refreshToken;

  AuthInterceptor({required Dio Function() dioFactory})
      : _dioFactory = dioFactory;

  void setTokens(String access, String refresh) {
    _accessToken = access;
    _refreshToken = refresh;
  }

  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    if (_accessToken != null) {
      options.headers['Authorization'] = 'Bearer $_accessToken';
    }
    handler.next(options);
  }

  @override
  void onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401 && _refreshToken != null) {
      try {
        final dio = _dioFactory();
        final response = await dio.post(
          '/auth/refresh',
          data: {'refreshToken': _refreshToken},
        );
        final newAccess = response.data['accessToken'];
        final newRefresh = response.data['refreshToken'];
        setTokens(newAccess, newRefresh);

        final retryOptions = err.requestOptions;
        retryOptions.headers['Authorization'] = 'Bearer $newAccess';
        final retryResponse = await dio.fetch(retryOptions);
        handler.resolve(retryResponse);
        return;
      } catch (_) {
        // Refresh token gagal, arahkan ke login
      }
    }
    handler.next(err);
  }
}

Inilah alur kerjanya. Di onRequest, kita sisipkan token ke header setiap kali request hendak dikirim. Tidak perlu menyentuh satu pun kode di layer repository atau UI.

Saat server mengembalikan status 401, onError langsung terpicu. Interceptor kita secara otomatis memanggil endpoint refresh token, menyimpan token baru, lalu mengulangi request yang gagal. Proses ini terjadi tanpa sepengetahuan pengguna, memberikan pengalaman autentikasi yang mulus.

Di sisi onResponse, kita bisa menormalkan struktur data. Misalnya server membungkus response dalam { "data": ..., "status": ... }. Interceptor bisa membuka bungkusan ini sehingga layer di bawahnya hanya melihat data murni tanpa perlu tahu format respons mentah dari server. Pendekatan ini membuat kode repository lebih bersih dan tidak perlu mengulang parsing struktur envelope yang sama di setiap metode.

Advanced Flutter State Management with BLoC
Mobile App • Intermediate

Advanced Flutter State Management with BLoC

Master advanced Flutter state management by building a production-ready applicat...

Daftar

Retry Logic dengan Interceptor untuk Menangani Gagal Request

Jaringan internet tidak selalu stabil. Request bisa gagal karena koneksi terputus, server kelebihan beban (status 502, 503), atau kita terkena rate limiting (429). Dalam situasi seperti ini, menyerah di percobaan pertama bukanlah strategi yang bijak.

Kita bisa membuat RetryInterceptor sendiri yang mengulangi request gagal dengan jeda yang semakin lama.

Diagram alur retry dengan exponential backoff — request gagal akan di-retry dengan delay yang meningkat secara eksponensial

Gambar: Flowchart retry logic dengan exponential backoff — error divisualisasikan sebagai retryable vs non-retryable, dengan delay berlipat ganda setiap percobaan — Sumber: [Mermaid](https://mermaid.js.org/)

dartdart
class RetryInterceptor extends Interceptor {
  final int maxRetries;
  final Duration baseDelay;

  RetryInterceptor({
    this.maxRetries = 3,
    this.baseDelay = const Duration(seconds: 1),
  });

  @override
  void onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (_shouldRetry(err) && _canRetry(err.requestOptions)) {
      for (var attempt = 1; attempt <= maxRetries; attempt++) {
        final delay = baseDelay * (1 << (attempt - 1));
        await Future.delayed(delay);

        try {
          final response = await Dio().fetch(err.requestOptions);
          handler.resolve(response);
          return;
        } catch (retryError) {
          if (attempt == maxRetries) {
            handler.next(err);
            return;
          }
        }
      }
    } else {
      handler.next(err);
    }
  }

  bool _shouldRetry(DioException err) {
    return err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.connectionError ||
        err.response?.statusCode == 429 ||
        err.response?.statusCode == 502 ||
        err.response?.statusCode == 503;
  }

  bool _canRetry(RequestOptions options) {
    return options.method == 'GET' || options.method == 'PUT';
  }
}

Logika retry di atas menerapkan exponential backoff: jeda antar percobaan berlipat ganda (1 detik, 2 detik, 4 detik). Ini mencegah banjir request ke server yang sedang sibuk. Kita juga membatasi metode yang boleh di-retry hanya GET dan PUT karena metode ini umumnya idempoten. POST tidak otomatis di-retry untuk menghindari duplikasi data.

Perhatikan penggunaan DioExceptionType.connectionTimeout dan connectionError. Kita hanya meretry error yang disebabkan oleh masalah jaringan, bukan error bisnis seperti 400 Bad Request. Ini penting untuk menghindari retry yang sia-sia.

Error Handling Terstruktur dengan DioException

DioException memiliki tipe yang beragam: connectionTimeout, sendTimeout, receiveTimeout, badResponse, connectionError, cancel, dan lainnya. Daripada menyebarkan exception mentah ini ke seluruh lapisan aplikasi, lebih baik kita petakan ke tipe domain sendiri.

Hierarki sealed class Failure — DioException dipetakan ke ServerFailure, NetworkFailure, dan TimeoutFailure

Gambar: Diagram hierarki sealed class Failure untuk error handling terstruktur — setiap tipe DioException dipetakan ke sub-class Failure yang sesuai — Sumber: [Mermaid](https://mermaid.js.org/)

dartdart
sealed class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  final int statusCode;
  const ServerFailure(super.message, this.statusCode);
}

class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}

class TimeoutFailure extends Failure {
  const TimeoutFailure(super.message);
}

Failure mapDioExceptionToFailure(DioException e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.sendTimeout:
    case DioExceptionType.receiveTimeout:
      return const TimeoutFailure(
        'Koneksi ke server memakan waktu terlalu lama. Silakan coba lagi.',
      );
    case DioExceptionType.connectionError:
      return const NetworkFailure(
        'Tidak dapat terhubung ke server. Periksa koneksi internet Anda.',
      );
    case DioExceptionType.badResponse:
      final statusCode = e.response?.statusCode ?? 0;
      final message = e.response?.data?['message'] ?? 'Terjadi kesalahan server.';
      return ServerFailure(message, statusCode);
    case DioExceptionType.cancel:
      return const Failure('Request dibatalkan.');
    default:
      return const Failure('Terjadi kesalahan yang tidak diketahui.');
  }
}

Dengan pola sealed class Failure, kita bisa menangani error secara eksplisit di repository atau bloc. Compiler akan memperingatkan jika ada tipe Failure yang belum ditangani, sehingga tidak ada skenario error yang terlewat tanpa sengaja. Selain itu, semua kemungkinan kegagalan terdokumentasi dalam satu hierarki tipe, memudahkan developer baru memahami keseluruhan alur error aplikasi.

Kapan menggunakan try/catch versus onError interceptor? Aturannya sederhana: gunakan interceptor untuk logic lintas sektor (seperti logging, retry, refresh token). Gunakan try/catch di repository untuk memetakan error spesifik domain. Keduanya tidak saling menggantikan, melainkan bekerja di layer yang berbeda.

Yang tidak boleh dilakukan adalah menampilkan DioException mentah ke pengguna. Pesan error seperti "Connection timed out" tidak bermakna bagi pengguna. Selalu bungkus dengan pesan yang manusiawi dan relevan dengan konteks.

Best Practices Arsitektur API Client dengan Dio

Struktur kode yang baik memisahkan tanggung jawab secara jelas. Rekomendasi arsitektur untuk Dio adalah:

ApiClient (Dio) -> Repository -> Bloc/Provider -> UI

ApiClient hanya bertanggung jawab untuk konfigurasi HTTP dan interceptor. Repository menggunakan ApiClient untuk mengambil data dan menerjemahkannya ke model domain. Bloc atau Provider mengelola state, sedangkan UI hanya menampilkan data.

Jangan pernah menggunakan Dio langsung di widget. Ini melanggar prinsip separation of concerns dan membuat kode sulit diuji. Setiap perubahan pada API akan memaksa kita mengedit puluhan file.

Gunakan CancelToken untuk membatalkan request yang tidak lagi diperlukan. Misalnya saat pengguna menavigasi keluar halaman sebelum data selesai dimuat:

dartdart
class ProductRepository {
  CancelToken? _cancelToken;

  Future<List<Product>> fetchProducts() async {
    _cancelToken?.cancel();
    _cancelToken = CancelToken();
    try {
      final response = await ApiClient().dio.get(
        '/products',
        cancelToken: _cancelToken,
      );
      return (response.data as List).map((e) => Product.fromJson(e)).toList();
    } catch (e) {
      if (e is DioException && e.type == DioExceptionType.cancel) {
        // Request dibatalkan, tidak perlu lakukan apa pun
      }
      rethrow;
    }
  }

  void dispose() {
    _cancelToken?.cancel();
  }
}

Aspek lain yang tidak kalah penting adalah testability. Dengan memisahkan ApiClient sebagai dependensi yang bisa digantikan, kita bisa menggunakan paket seperti mocktail untuk mensimulasikan response server dalam unit test. Ini memastikan layer repository dan bloc bisa diuji secara menyeluruh tanpa perlu koneksi internet yang sebenarnya.

Terakhir, perhatikan level logging. Di mode development, LogInterceptor sangat membantu. Di mode production, nonaktifkan atau setel ke level yang hanya mencatat error. Informasi header dan body request tidak boleh bocor ke log produksi karena bisa mengandung data sensitif.

Sebagai ringkasan, pastikan arsitektur Dio kita memiliki:

  • Instance singleton dengan BaseOptions terpusat
  • Interceptor chain yang terurut: Logging -> Auth -> Retry
  • Exponential backoff untuk retry dengan filter metode idempoten
  • Error handling terstruktur dengan sealed class atau pattern matching
  • Pemisahan layer yang tegas antara HTTP client, repository, dan UI

Tertarik menguasai Flutter secara profesional? Ikuti bootcamp Mobile Development Rumah Coding dan bangun aplikasi production-ready dari nol bersama mentor berpengalaman.

Course Terkait

TaskSync: Real-Time Collaborative Task Manager
Premium Course Mobile App

Advanced Flutter State Management with BLoC

Master advanced Flutter state management by building a production-ready application. This intermediate course uses a top-down, problem-driven approach, plunging you into real-world engineering challenges. You will learn to architect scalable applications, handle complex reactive states, manage multi-BLoC communication, synchronize real-time data, and implement optimistic UI updates using industry-standard BLoC patterns.

Capstone Project

TaskSync: Real-Time Collaborative Task Manager

  • Role-Based Authentication: Secure login and session management, dynamically reflecting user states across the entire application.
  • Real-Time Task Board: A Kanban-style board that instantly updates across all devices when any team member creates, moves, or deletes a task.
  • Advanced Search & Filtering: High-performance local search with event debouncing to prevent unnecessary API calls.
7 Weeks Intermediate
Lihat Detail Course
DailyQuest: Gamified Habit Tracker
Premium Course Mobile App

Flutter Mobile Development

Launch your mobile development journey with this immersive, project-based Flutter course. Designed specifically for beginners, this program takes you from coding fundamentals in Dart to deploying a fully functional mobile app. You will learn to craft beautiful, responsive UIs, handle global state management, and integrate cloud backends. By the end of the course, you will have built a real-world, cloud-synced application from scratch.

Capstone Project

DailyQuest: Gamified Habit Tracker

  • Secure Authentication: User registration and login functionality using email and password.
  • Cloud Data Synchronization: Real-time database integration (using Supabase or Firebase) to securely store and retrieve user habits.
  • Full CRUD Operations: The ability for users to Create, Read, Update, and Delete their daily tasks and habits.
7 Weeks Beginner
Lihat Detail Course

Artikel Terkait