Koneksi perangkat bergerak (mobile) sering mengalami degradasi jaringan mendadak seperti peralihan seluler ke Wi-Fi atau dead zone. Kondisi ini kerap memicu half-open connection: klien mengirim mutasi HTTP POST, backend sukses memproses transaksi dan menulis ke database, namun paket response HTTP drop sebelum mencapai perangkat. Klien menganggap request mengalami timeout (ECONNABORTED) dan memicu retry otomatis. Tanpa mekanisme idempotensi, retry tersebut menciptakan data ganda pada sistem perbankan, inventaris, atau pemesanan.
Akar Masalah: Response Packet Drop pada Jaringan Seluler
Protokol TCP menjamin keandalan pengiriman paket, namun tidak menjamin ketersediaan layer aplikasi ketika soket terputus di tengah jalan. Siklus terjadinya duplikasi mutasi dijabarkan dalam urutan berikut:
- Klien React Native mengirim request
POST /api/v1/orders. - Backend menerima request, membuka database transaction, mendebit saldo atau memotong inventaris, lalu commit.
- Sesaat sebelum backend mengirim status HTTP 201, perangkat pengguna berpindah BTS seluler atau kehilangan sinyal. Paket TCP FIN/RST atau HTTP response hilang.
- Klien React Native mencapai batas
timeout(misalnya 15 detik), memunculkan error network, lalu logic aplikasi atau interceptor menjalankan retry otomatis. - Backend menerima request kedua tanpa penanda identitas mutasi yang sama, mengeksekusinya sebagai transaksi baru, dan menyebabkan eksekusi ganda.
Solusi arsitektur untuk masalah ini adalah penerapan Idempotency Key: penanda unik berbasis UUID v4 yang dikirimkan oleh klien via HTTP header pada setiap aksi mutasi. Backend memakai key tersebut sebagai locking key dan cache key hasil eksekusi.
Persistensi Idempotency Key dengan MMKV Sebelum Dispatch
Pembuatan key tidak boleh dilakukan sesaat sebelum fetch tanpa persistensi lokal. Jika aplikasi crash atau thread terhenti tepat saat mutasi di-dispatch, key yang sama harus tetap digunakan untuk percobaan berikutnya.
Gunakan pustaka react-native-mmkv karena menyediakan akses sinkronus berbasis memori mmap C++. Operasi penyimpanan sinkronus menjamin key tertulis ke storage sebelum network frame dialokasikan oleh engine networking native (OkHttp di Android / NSURLSession di iOS).
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({ id: 'idempotency-storage' });
interface PendingMutation {
key: string;
createdAt: number;
}
export const getOrCreateIdempotencyKey = (mutationId: string): string => {
const existingRecord = storage.getString(mutationId);
if (existingRecord) {
const parsed: PendingMutation = JSON.parse(existingRecord);
return parsed.key;
}
// Generate UUID v4
const newKey = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
storage.set(mutationId, JSON.stringify({ key: newKey, createdAt: Date.now() }));
return newKey;
};
export const clearIdempotencyKey = (mutationId: string): void => {
storage.delete(mutationId);
};Implementasi Axios Interceptor dan Retry Full Jitter
Retry hanya boleh dieksekusi untuk error jaringan yang bersifat transient: timeout jaringan, socket hang up, atau status kode server 502, 503, dan 504. Jangan melakukan retry otomatis pada error 4xx karena kesalahan validasi atau otorisasi tidak akan berubah pada percobaan berikutnya.
Gunakan rumus Full Jitter dari AWS Architecture. Rumus ini menyebarkan lonjakan request retry secara merata sehingga tidak membebani server yang sedang memulihkan diri (thundering herd problem):
Sleep = rand(0, min(Cap, Base * 2 ^ attempt))
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { getOrCreateIdempotencyKey, clearIdempotencyKey } from './idempotencyStorage';
declare module 'axios' {
export interface AxiosRequestConfig {
mutationId?: string;
retryCount?: number;
}
}
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 8000;
const calculateFullJitter = (attempt: number): number => {
const exponentialDelay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
return Math.floor(Math.random() * exponentialDelay);
};
const isTransientNetworkError = (error: AxiosError): boolean => {
if (!error.response) {
// Network dropped, DNS lookup failed, atau timeout (ECONNABORTED)
return true;
}
const status = error.response.status;
return status === 408 || (status >= 502 && status <= 504);
};
export const setupIdempotentClient = (api: AxiosInstance): void => {
// Request Interceptor: Attach Header
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (config.mutationId && config.method && ['post', 'put', 'patch'].includes(config.method.toLowerCase())) {
const idempotencyKey = getOrCreateIdempotencyKey(config.mutationId);
config.headers.set('Idempotency-Key', idempotencyKey);
}
return config;
});
// Response Interceptor: Lifecycle & Retry
api.interceptors.response.use(
(response) => {
// Mutasi tuntas dengan sukses: bersihkan key dari storage
if (response.config.mutationId) {
clearIdempotencyKey(response.config.mutationId);
}
return response;
},
async (error: AxiosError) => {
const config = error.config;
if (!config || !config.mutationId || !isTransientNetworkError(error)) {
// Hapus key jika error permanen (misal HTTP 400/422) agar request perbaikan memakai key baru
if (config?.mutationId && error.response && error.response.status < 500 && error.response.status !== 409) {
clearIdempotencyKey(config.mutationId);
}
return Promise.reject(error);
}
config.retryCount = config.retryCount ?? 0;
if (config.retryCount >= MAX_RETRIES) {
return Promise.reject(error);
}
config.retryCount += 1;
const delay = calculateFullJitter(config.retryCount);
await new Promise((resolve) => setTimeout(resolve, delay));
return api(config);
}
);
};Kontrak Respon API: HTTP 409 vs Replay Cache
Backend yang mengimplementasikan idempotensi harus merespons transaksi sesuai state pemrosesan di database atau distributed lock (misalnya Redis Redlock). Klien wajib menginterpretasikan status code secara tepat:
1. HTTP 200 / 201 Cache Replay
Ketika server mendeteksi Idempotency-Key yang sudah selesai diproses pada mutasi sebelumnya, server tidak boleh mengeksekusi ulang logika bisnis. Server mengembalikan response body yang identik beserta header penanda seperti X-Cache-Lookup: HIT atau Idempotency-Replayed: true. Klien React Native memperlakukan respons ini sebagai mutasi sukses tanpa menampilkan warning error.
2. HTTP 409 Conflict (In-Flight Lock)
Jika server menerima request dengan key yang statusnya masih berjalan di worker/transaksi lain (in-flight), server mengembalikan status 409 Conflict dengan body terstruktur:
{
"code": "MUTATION_IN_PROGRESS",
"message": "Permintaan ini sedang diproses di server."
}Tindakan Klien: Jangan melakukan retry mutasi secara langsung. Polling status resource dengan HTTP GET atau hentikan UI spinner dan beri tahu pengguna bahwa transaksi sedang diverifikasi.
Siklus Pembersihan (Lifecycle Cleanup)
Data key pada MMKV tidak boleh menumpuk. Kunci mutasi dibersihkan pada kondisi berikut:
- Terminal Success: Response HTTP 200, 201, atau 204 diterima. Key langsung dihapus via
clearIdempotencyKey(mutationId). - Unrecoverable Client Error: Response HTTP 400, 401, 403, 422. Key dibersihkan karena input data ditolak server; pengguna harus mengulang pengisian form yang menghasilkan mutasi baru.
- Storage TTL Expiry: Saat aplikasi dibuka (cold start), jalankan garbage collection untuk membersihkan pending keys yang umurnya melebihi ambang batas (misal 24 jam) guna mencegah memory leak lokal jika aplikasi di-force-close saat request berjalan.
export const purgeStaleIdempotencyKeys = (maxAgeMs: number = 24 * 60 * 60 * 1000): void => {
const keys = storage.getAllKeys();
const now = Date.now();
for (const k of keys) {
const raw = storage.getString(k);
if (raw) {
try {
const parsed: PendingMutation = JSON.parse(raw);
if (now - parsed.createdAt > maxAgeMs) {
storage.delete(k);
}
} catch {
storage.delete(k);
}
}
}
};Panggil fungsi purgeStaleIdempotencyKeys() di root komponen atau initialization block React Native sebelum render root provider.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!