Pada aplikasi seluler, koneksi jaringan bersifat volatil. Salah satu skenario paling berbahaya dalam transaksi finansial atau mutasi data adalah two-phase disconnect: client mengirim POST /api/v1/payments, backend berhasil memproses mutasi dan menulis ke database, namun koneksi seluler terputus sebelum client menerima respons 200 OK.

Dari sudut pandang React Native, request mengalami ECONNABORTED atau Network Error. Jika UI mengizinkan user menekan tombol kembali, atau mekanisme auto-retry mengirim ulang request mentah, backend akan mengeksekusi mutasi untuk kedua kalinya. Solusi standar industri untuk masalah ini adalah penerapan Idempotency-Key.

Anatomi Masalah: Skenario Network Timeout

HTTP method seperti POST dan PATCH secara default bersifat non-idempoten. Ketika request dikirim:

  1. Client menginisiasi koneksi TCP/TLS dan mengirim payload mutasi.
  2. Server menerima payload, membuka database transaction, memperbarui saldo, dan melakukan commit.
  3. Server menyusun respons HTTP dan mencoba mengirimkannya melalui socket.
  4. Tower seluler client mengalami handover gagal atau kehilangan sinyal seketika. Socket tertutup sepihak.
  5. Runtime React Native (Fetch/Axios) memicu rejection: timeout.

Jika client mengulang request tanpa identitas unik deterministik, server tidak memiliki konteks apakah request tersebut adalah percobaan ulang dari transaksi sebelumnya atau transaksi baru dengan nilai yang kebetulan sama.

Prinsip Idempotency-Key

Mekanisme ini bekerja dengan melampirkan UUID v4 unik pada header Idempotency-Key untuk setiap mutasi spesifik. Server menyimpan key ini di storage in-memory terdistribusi (misal: Redis) dengan status execution state: STARTED atau FINISHED beserta hasil respons HTTP aslinya.

Agar arsitektur ini solid di React Native, client harus mematuhi tiga aturan:

  • Deterministic Lifecycle: Key tidak boleh di-generate ulang di dalam retry loop. Key harus dibuat satu kali ketika user mengonfirmasi aksi di UI.
  • Write-Ahead Persistence: Key harus disimpan ke storage lokal sinkron sebelum transmisi soket dimulai untuk mencegah hilangnya status saat terjadi force kill/crash.
  • Safe Replay Handling: Interceptor harus mampu membedakan error jaringan murni dengan status 409 Conflict (transaksi sedang berlangsung di worker lain) atau membaca payload cached replay.

Persistensi Key via MMKV

Gunakan storage berkecepatan tinggi dengan akses sinkron berbasis memory-mapped I/O (mmap) seperti react-native-mmkv. Pendekatan asinkron seperti AsyncStorage berisiko mengalami race condition jika aplikasi dimatikan OS saat proses disk writing sedang dijadwalkan di background queue.

import { MMKV } from 'react-native-mmkv';

export const idempStorage = new MMKV({ id: 'mutation-idempotency-vault' });

export function getOrCreateMutationKey(mutationId: string): string {
  const existingKey = idempStorage.getString(mutationId);
  if (existingKey) {
    return existingKey;
  }
  
  // Buat UUID baru jika transaksi belum pernah diinisiasi
  const newKey = crypto.randomUUID();
  idempStorage.set(mutationId, newKey);
  return newKey;
}

export function clearMutationKey(mutationId: string): void {
  idempStorage.delete(mutationId);
}

Implementasi Interceptor Axios: Backoff, Jitter, dan Status 409

Retry tanpa jitter dapat memicu thundering herd problem pada server yang sedang mengalami degradasi. Gunakan kalkulasi Full Jitter:

Sleep = rand(0, min(MaxBackoff, BaseBackoff * 2 ^ RetryAttempt))

Berikut implementasi custom Axios interceptor yang mengelola header Idempotency-Key, retry otomatis saat timeout, dan penanganan respons cached/conflict:

import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { clearMutationKey } from './idempStorage';

interface IdempotentConfig extends InternalAxiosRequestConfig {
  _retryCount?: number;
  mutationId?: string;
  idempotencyKey?: string;
}

const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 10000;

function calculateJitterBackoff(attempt: number): number {
  const exponential = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt));
  return Math.floor(Math.random() * exponential);
}

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export function setupIdempotentClient(client: AxiosInstance): void {
  client.interceptors.request.use((config: IdempotentConfig) => {
    // Inject header jika request membawa context idempotencyKey
    if (config.idempotencyKey) {
      config.headers.set('Idempotency-Key', config.idempotencyKey);
    }
    return config;
  });

  client.interceptors.response.use(
    (response) => {
      const config = response.config as IdempotentConfig;
      // Bersihkan key lokal jika mutasi selesai secara absolut
      if (config.mutationId) {
        clearMutationKey(config.mutationId);
      }
      return response;
    },
    async (error: AxiosError) => {
      const config = error.config as IdempotentConfig | undefined;

      if (!config || !config.idempotencyKey) {
        return Promise.reject(error);
      }

      config._retryCount = config._retryCount ?? 0;

      // Kasus 1: Network Timeout atau Network Error (Connection Drop)
      const isNetworkIssue = !error.response || error.code === 'ECONNABORTED';

      // Kasus 2: Backend 409 Conflict (Lock didapat worker lain, transaksi sedang running)
      const isConflict = error.response?.status === 409;

      if ((isNetworkIssue || isConflict) && config._retryCount < MAX_RETRIES) {
        config._retryCount += 1;
        const delay = calculateJitterBackoff(config._retryCount);

        await wait(delay);

        // Kirim ulang request dengan Idempotency-Key yang SAMA PERSIS
        return client.request(config);
      }

      // Kasus 3: Backend merespons payload cache lama (status 200 via header khusus backend)
      // Jika backend mengirim 400 Bad Request karena hash payload mismatch, jangan retry.
      if (error.response?.status === 422 || error.response?.status === 400) {
        if (config.mutationId) {
          clearMutationKey(config.mutationId);
        }
      }

      return Promise.reject(error);
    }
  );
}

Penggunaan pada React Native Hook / Mutation

Kaitkan pembuatan key dengan UI-action trigger (misal penekanan tombol checkout), bukan di dalam lifecycle render atau interceptor dinamis tanpa state:

import { useState } from 'react';
import axios from 'axios';
import { getOrCreateMutationKey, clearMutationKey } from './idempStorage';
import { setupIdempotentClient } from './idempotentClient';

const apiClient = axios.create({ baseURL: 'https://api.domain.com/v1', timeout: 5000 });
setupIdempotentClient(apiClient);

export function useCheckout() {
  const [loading, setLoading] = useState(false);

  const executeCheckout = async (orderId: string, amount: number) => {
    setLoading(true);
    // mutationId unik mengikat entitas UI dengan siklus hidup network
    const mutationId = `checkout_${orderId}`;
    const idempotencyKey = getOrCreateMutationKey(mutationId);

    try {
      const response = await apiClient.post(
        '/checkout',
        { orderId, amount },
        { mutationId, idempotencyKey }
      );
      return response.data;
    } catch (error) {
      // Tangani error permanen jika retry gagal total
      throw error;
    } finally {
      setLoading(false);
    }
  };

  return { executeCheckout, loading };
}

Edge Cases dan Aturan Integritas

Peringatan: Idempotency-Key menjamin deduplikasi, bukan validasi integritas payload. Jika payload berubah tetapi key sama, server wajib melempar error.

  • Payload Mismatch: Server harus menyimpan SHA-256 hash dari payload pertama yang diterima bersama key tersebut. Jika retry berikutnya membawa key yang sama namun nilai payload berbeda, backend harus menolak dengan kode status 422 Unprocessable Entity atau 400 Bad Request.
  • TTL Synchronization: Key di local storage MMKV dan di Redis server harus memiliki time-to-live (TTL). Standar TTL berkisar antara 24 hingga 48 jam tergantung siklus settlement transaksi.
  • Unmount / Background Termination: Jika app di-kill oleh user saat proses mutasi, state UI hilang. Namun, saat app dibuka kembali, recovery worker dapat memeriksa daftar key di MMKV dan melakukan query status transaksi sebelum mengizinkan user menekan tombol kembali.