kodingup.camp

Beranda/Various Library Mastery

Interceptor dan Penanganan Kegagalan

Modul 8 dari 134 menit bacaInterceptor & Failure Handling

Isi modul

    Kenapa ini penting

    Bayangkan setiap request ke API butuh token login di header Authorization. Kalau ada 30 endpoint, kamu harus menulis header itu 30 kali, dan satu saja terlupa, user mendapat error aneh. Lalu, kalau token kedaluwarsa dan server membalas 401, aplikasi harus mengeluarkan user di mana pun request itu terjadi.

    Interceptor adalah "pos pemeriksaan" yang dilewati setiap request sebelum berangkat dan setiap respons sebelum sampai ke kodemu. Dengan interceptor, logika seperti menempel token, menangani 401, mencatat error, dan retry cukup ditulis sekali.

    Analogi: interceptor itu seperti satpam di pintu gedung kantor. Setiap tamu yang keluar dicek sudah membawa kartu identitas (request interceptor). Setiap paket yang masuk diperiksa dulu, kalau paketnya rusak atau salah alamat, satpam yang memutuskan apa tindakannya (response interceptor).

    Konsep inti

    Request interceptor

    api.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem("token");
        if (token) config.headers.Authorization = `Bearer ${token}`;
        return config; // WAJIB dikembalikan
      },
      (error) => Promise.reject(error)
    );
    

    Fungsi pertama menerima config (berisi url, method, headers, dll.), boleh mengubahnya, lalu harus mengembalikannya. Kalau lupa return config, request tidak akan terkirim dengan benar.

    Response interceptor

    api.interceptors.response.use(
      (response) => response, // status 2xx lewat sini
      (error) => {
        // status di luar 2xx, timeout, atau jaringan putus lewat sini
        return Promise.reject(error);
      }
    );
    

    Fungsi kedua menerima error. Kamu bisa menanganinya (logout, tampilkan notifikasi) lalu tetap Promise.reject(error) supaya kode pemanggil (misalnya TanStack Query) tahu request gagal.

    Anatomi error axios

    Properti Isi
    error.response Ada kalau server membalas. Berisi status, data, headers
    error.request Ada kalau request terkirim tapi tidak ada balasan (server mati, jaringan putus)
    error.code Kode seperti "ECONNABORTED" (timeout) atau "ERR_NETWORK"
    error.message Pesan bawaan, misalnya "timeout of 500ms exceeded"
    error.config Konfigurasi request yang gagal, berguna untuk retry

    Gunakan axios.isAxiosError(error) untuk memastikan error berasal dari axios, bukan bug JavaScript lain.

    Menangani status penting

    Status / kasus Arti Tindakan umum
    400 / 422 Data yang dikirim tidak valid Tampilkan pesan validasi dari server di form
    401 Belum login atau token kedaluwarsa Hapus token, arahkan ke halaman login
    403 Login, tapi tidak punya izin Tampilkan "Akses ditolak"
    404 Data tidak ditemukan Tampilkan halaman/pesan "tidak ditemukan"
    500 / 502 / 503 Kesalahan server Pesan "Server sedang bermasalah, coba lagi" + boleh retry
    Timeout (ECONNABORTED) Server terlalu lama Pesan "Koneksi lambat" + boleh retry
    Tanpa response Jaringan putus Pesan "Periksa koneksi internet"

    Timeout

    timeout (milidetik) di config membatalkan request yang terlalu lama. Default axios adalah 0 artinya tanpa batas, request bisa menggantung selamanya. Selalu atur timeout yang masuk akal, misalnya 10 detik.

    Retry

    Retry artinya mencoba ulang request yang gagal. Aturannya:

    Kalau kamu memakai TanStack Query, ia sudah melakukan retry untuk query (default 3 kali). Jadi untuk GET yang lewat useQuery, kamu cukup mengatur opsi retry di sana, dan tidak perlu retry di interceptor juga, kalau keduanya aktif, satu kegagalan bisa menjadi belasan request.

    Contoh kode

    // src/lib/api.js, instance dengan interceptor lengkap
    import axios from "axios";
    
    export const api = axios.create({
      baseURL: "https://jsonplaceholder.typicode.com",
      timeout: 10000,
    });
    
    // 1) Request interceptor: tempel token
    api.interceptors.request.use((config) => {
      const token = localStorage.getItem("token");
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    });
    
    // 2) Ubah error mentah menjadi pesan yang ramah
    export function pesanError(error) {
      if (!axios.isAxiosError(error)) return "Terjadi kesalahan tak terduga.";
      if (error.code === "ECONNABORTED") return "Server terlalu lama merespons. Coba lagi.";
      if (!error.response) return "Tidak bisa terhubung. Periksa koneksi internetmu.";
    
      const { status, data } = error.response;
      if (status === 401) return "Sesi kamu berakhir. Silakan login lagi.";
      if (status === 403) return "Kamu tidak punya akses untuk tindakan ini.";
      if (status === 404) return "Data tidak ditemukan.";
      if (status >= 500) return "Server sedang bermasalah. Coba beberapa saat lagi.";
      return data?.message ?? `Permintaan gagal (HTTP ${status}).`;
    }
    
    // 3) Response interceptor: tangani 401 secara global + retry GET untuk error sementara
    const tunggu = (ms) => new Promise((r) => setTimeout(r, ms));
    
    api.interceptors.response.use(
      (response) => response,
      async (error) => {
        const config = error.config;
        const status = error.response?.status;
    
        if (status === 401) {
          localStorage.removeItem("token");
          window.location.assign("/login"); // arahkan ke halaman login
          return Promise.reject(error);
        }
    
        const sementara = !error.response || error.code === "ECONNABORTED" || status >= 500;
        const bolehRetry = config && config.method === "get" && !config.skipRetry;
    
        if (sementara && bolehRetry) {
          config.__retryCount = (config.__retryCount ?? 0) + 1;
          if (config.__retryCount <= 2) {
            await tunggu(500 * 2 ** (config.__retryCount - 1)); // 500 ms, lalu 1000 ms
            return api(config); // kirim ulang request yang sama
          }
        }
    
        // ubah pesan supaya komponen tinggal menampilkan error.message
        error.message = pesanError(error);
        return Promise.reject(error);
      }
    );
    
    // src/ErrorDemo.jsx
    import { useState } from "react";
    import { api } from "./lib/api";
    
    export default function ErrorDemo() {
      const [pesan, setPesan] = useState("");
    
      async function coba(path, config) {
        try {
          const { data } = await api.get(path, config);
          setPesan(`Sukses: ${JSON.stringify(data).slice(0, 60)}...`);
        } catch (err) {
          setPesan(err.message);
        }
      }
    
      return (
        <div className="space-y-2 p-6">
          <button onClick={() => coba("/todos/1")}>Request normal</button>
          <button onClick={() => coba("/todos/999999")}>Request 404</button>
          <button onClick={() => coba("/todos", { timeout: 1 })}>Request timeout</button>
          <p>{pesan}</p>
        </div>
      );
    }
    // → Request normal : Sukses: {"userId":1,"id":1,"title":"delectus aut autem",...
    // → Request 404    : Data tidak ditemukan.
    // → Request timeout: (dicoba ulang 2 kali) lalu "Server terlalu lama merespons. Coba lagi."
    

    Perhatikan bahwa retry di contoh ini memakai config.timeout yang sama (1 ms), sehingga ketiganya gagal, persis seperti yang kita harapkan untuk demo. Opsi skipRetry adalah properti buatan kita sendiri; axios meneruskan properti config tambahan ke interceptor.

    Kesalahan umum

    Latihan

    1. Mudah, Tambahkan request interceptor yang menempelkan header Accept-Language: id ke setiap request.
    2. Sedang, Tambahkan request interceptor yang mencatat METHOD url di console, dan response interceptor yang mencatat lama request dalam milidetik.
    3. Menantang, Ubah interceptor 401 supaya tidak memakai localStorage langsung, melainkan memanggil useAuthStore.getState().logout() dari Zustand (modul Middleware). Jelaskan kenapa getState() bisa dipakai di file non-komponen.
    Lihat solusi
    // 1.
    api.interceptors.request.use((config) => {
      config.headers["Accept-Language"] = "id";
      return config;
    });
    
    // 2. Logging + durasi
    api.interceptors.request.use((config) => {
      config.metadata = { mulai: performance.now() };
      console.log(`${config.method.toUpperCase()} ${config.baseURL}${config.url}`);
      return config;
    });
    
    api.interceptors.response.use(
      (response) => {
        const durasi = Math.round(performance.now() - response.config.metadata.mulai);
        console.log(`selesai dalam ${durasi} ms`);
        return response;
      },
      (error) => Promise.reject(error)
    );
    // → GET https://jsonplaceholder.typicode.com/todos/1
    // → selesai dalam 143 ms
    
    // 3. Integrasi dengan Zustand
    import { useAuthStore } from "../stores/useAuthStore";
    
    api.interceptors.request.use((config) => {
      const token = useAuthStore.getState().token; // baca tanpa hook
      if (token) config.headers.Authorization = `Bearer ${token}`;
      return config;
    });
    
    api.interceptors.response.use(
      (r) => r,
      (error) => {
        if (error.response?.status === 401) {
          useAuthStore.getState().logout(); // persist ikut menghapus dari localStorage
        }
        return Promise.reject(error);
      }
    );
    // getState() bisa dipakai di luar komponen karena store Zustand adalah object biasa
    // di level modul; hook hanya diperlukan kalau komponen ingin re-render saat state berubah.
    

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.