Akar Masalah: Koneksi Putus Pasca-Commit Database
Masalah duplikasi data pada operasi POST bukan disebabkan oleh bug logika database, melainkan karakteristik jaringan seluler yang tidak dapat diandalkan (fallacies of distributed computing). Saat aplikasi React Native mengirim mutasi finansial atau pembuatan order, terdapat tiga fase komunikasi:
- Klien mengirim request ke server.
- Server memproses mutasi, commit ke database, lalu menyiapkan respons.
- Server mengirim respons HTTP kembali ke klien.
Kegagalan jaringan paling sering terjadi pada fase ketiga. Handshake TCP atau koneksi TLS terputus akibat fluktuasi sinyal radio seluler saat paket respons sedang dalam perjalanan. Di sisi server, transaksi berhasil dicatat. Di sisi klien React Native, runtime melemparkan exception TypeError: Network request failed atau ECONNABORTED.
Jika klien mengaktifkan mekanisme auto-retry standar atau pengguna menekan tombol kembali, klien mengirim payload identik ke server. Server yang tidak mengimplementasikan pemeriksaan idempoten akan memperlakukan request tersebut sebagai transaksi baru, memicu duplikasi data atau double-charge.
Prinsip User Intent Scope vs Retry Scope
Kesalahan fatal dalam implementasi retry client-side adalah meng-generate UUID baru di dalam interceptor atau perulangan (retry loop). Pola tersebut keliru karena setiap retry dianggap sebagai entitas transaksi unik oleh backend.
Aturan Desain:
Idempotency-Keyharus di-generate tepat saat aksi pengguna dimulai (user intent) dan wajib dipertahankan sama di seluruh siklus percobaan ulang (retry attempts) untuk transaksi tersebut.
Alur yang benar:
- Pengguna menekan tombol bayar → generate UUID v4 satu kali.
- Simpan key ke local storage sinkron (MMKV).
- Kirim request dengan header
Idempotency-Key: <UUID>. - Jika timeout, kirim ulang dengan payload dan
Idempotency-Keyyang persis sama. - Hapus key dari storage hanya jika server mengembalikan respons final yang valid.
Persistensi Sementara dengan MMKV
Penyimpanan key di memori (in-memory state) tidak cukup. Jika aplikasi mengalami out-of-memory (OOM) crash di background saat menunggu respons jaringan, user akan membuka ulang aplikasi dan mencoba kembali. Klien membutuhkan storage sinkron berkecepatan tinggi sebelum paket HTTP keluar dari socket.
Gunakan react-native-mmkv karena operasinya sinkron (JSI-based), mencegah race condition antara generate key dan proses serialize payload:
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({ id: 'idempotency-cache' });
export function getOrCreateIdempotencyKey(intentId: string): string {
const existingKey = storage.getString(intentId);
if (existingKey) {
return existingKey;
}
// ponytail: crypto.randomUUID() tersedia di runtime modern. Gunakan polyfill jika di Hermes versi lawas.
const newKey = crypto.randomUUID();
storage.set(intentId, newKey);
return newKey;
}
export function clearIdempotencyKey(intentId: string): void {
storage.delete(intentId);
}Implementasi Fetch Client dengan Exponential Backoff
Kode berikut mengisolasi logika eksekusi HTTP dengan Idempotency-Key, exponential backoff dengan jitter acak, serta penanganan status kode 409 Conflict dan replay 200/201.
interface RequestOptions extends RequestInit {
intentId: string;
maxRetries?: number;
baseDelayMs?: number;
}
interface ApiResponse<T> {
status: number;
data: T;
replayed: boolean;
}
export async function idempotentMutate<T>(
url: string,
options: RequestOptions
): Promise<ApiResponse<T>> {
const { intentId, maxRetries = 3, baseDelayMs = 1000, headers, ...restOptions } = options;
const idempotencyKey = getOrCreateIdempotencyKey(intentId);
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url, {
...restOptions,
headers: {
...headers,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
});
// 409 Conflict: Backend sedang memproses request dengan key ini dari request sebelumnya
if (response.status === 409) {
attempt++;
if (attempt >= maxRetries) {
throw new Error('Transaction is currently processing. Please check transaction history.');
}
await sleep(calculateBackoff(attempt, baseDelayMs));
continue;
}
if (!response.ok) {
// Jangan retry client error (4xx) kecuali 409 atau 429
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
clearIdempotencyKey(intentId);
throw new Error(`Client mutation rejected: ${response.status}`);
}
throw new Error(`Server returned status: ${response.status}`);
}
const data: T = await response.json();
const replayed = response.headers.get('Idempotent-Replayed') === 'true';
// Transaksi sukses tercatat, hapus kunci dari MMKV
clearIdempotencyKey(intentId);
return {
status: response.status,
data,
replayed,
};
} catch (err: any) {
attempt++;
if (attempt >= maxRetries) {
// Jangan hapus key dari storage di sini agar recovery intent tetap valid
throw err;
}
await sleep(calculateBackoff(attempt, baseDelayMs));
}
}
throw new Error('Max retries exceeded');
}
function calculateBackoff(attempt: number, baseDelay: number): number {
// Full jitter: Math.random() * (baseDelay * 2^attempt)
const exponential = baseDelay * Math.pow(2, attempt);
return Math.floor(Math.random() * exponential);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Handling Status Respons: Replay vs Conflict
1. Header Replay (200 OK / 201 Created)
Ketika backend menerima request dengan key yang sudah pernah sukses diproses, backend tidak mengeksekusi mutasi ulang. Backend mengambil payload respons asli dari cache/database dan mengembalikannya ke klien bersama header identifikasi, misalnya Idempotent-Replayed: true.
Klien harus menganggap ini sebagai operasi sukses, bukan error. UI harus memperbarui state lokal seolah-olah data baru saja diproses pertama kali.
2. HTTP 409 Conflict
Status 409 terjadi saat request pertama masih dalam status transit/dieksekusi worker backend (misal: lock Redis pada key masih aktif), lalu paket retry tiba lebih awal. Respons ini menandakan resource terkunci. Penanganan klien: lakukan delay backoff lebih panjang, jangan membuat key baru, dan coba tarik status transaksi.
Verifikasi: Runnable Assertion Test
Skrip verifikasi di bawah ini menguji kepatuhan siklus hidup idempotency key. Skrip memastikan bahwa perulangan retry tidak pernah mengubah key asli yang di-generate pada permulaan intent.
// verify-idempotency.ts
// Jalankan langsung dengan: ts-node verify-idempotency.ts
const mockStorage = new Map<string, string>();
function testGetOrCreateKey(intentId: string): string {
if (mockStorage.has(intentId)) {
return mockStorage.get(intentId)!;
}
const generated = 'key-' + Math.random().toString(36).substring(2, 9);
mockStorage.set(intentId, generated);
return generated;
}
async function runTest() {
const intentId = 'checkout-cart-9921';
const networkLogs: string[] = [];
// Mock server yang gagal pada 2 request awal lalu sukses di request ke-3
let serverCallCount = 0;
const mockServerFetch = async (headers: Record<string, string>) => {
serverCallCount++;
networkLogs.push(headers['Idempotency-Key']);
if (serverCallCount < 3) {
throw new Error('TCP Drop Simulation');
}
return { ok: true, status: 201 };
};
// Intent run
const initialKey = testGetOrCreateKey(intentId);
let attempt = 0;
let succeeded = false;
while (attempt < 3 && !succeeded) {
try {
const key = testGetOrCreateKey(intentId);
const res = await mockServerFetch({ 'Idempotency-Key': key });
if (res.ok) {
succeeded = true;
mockStorage.delete(intentId);
}
} catch {
attempt++;
}
}
// Assertion 1: Jumlah network calls adalah 3
console.assert(networkLogs.length === 3, 'Gagal: Request harus dicoba sebanyak 3 kali');
// Assertion 2: Idempotency Key harus sama persis di ketiga percobaan
const allKeysMatch = networkLogs.every((k) => k === initialKey);
console.assert(allKeysMatch, 'Fatal: Idempotency key berubah di tengah-tengah retry!');
// Assertion 3: Storage harus bersih setelah mutasi selesai
console.assert(!mockStorage.has(intentId), 'Gagal: Key intent tidak dibersihkan setelah sukses');
console.log('Semua pengujian lolos: Konsistensi Idempotency-Key valid.');
}
runTest();
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!