Anatomi Masalah: Network Timeout dan Double Spending

Pada arsitektur client-server, kegagalan jaringan setelah backend mengeksekusi operasi mutasi (POST/PATCH) adalah penyebab utama duplikasi data. Pertimbangkan alur transaksi berikut:

  1. Klien mengirimkan request pembayaran POST /api/v1/payments/.
  2. Server menerima request, memotong saldo database, dan menghasilkan rekor transaksi.
  3. Koneksi jaringan terputus sebelum server berhasil mengirimkan respon 201 Created kembali ke klien.
  4. Klien mengalami network timeout dan secara otomatis mengeksekusi mekanisme retry dengan muatan data yang sama.
  5. Server yang naif memproses request kedua sebagai transaksi baru, mengakibatkan pemotongan saldo ganda (double spending).

Solusi standar industri untuk masalah ini adalah memberlakukan kontrak idempotensi via HTTP header Idempotency-Key. Header ini berisi token unik (umumnya UUIDv4) yang dikirim klien untuk mengidentifikasi request secara unik di sepanjang siklus hidup eksekusi.

Arsitektur Solusi Idempotensi

Implementasi idempotensi pada Django REST Framework (DRF) memerlukan tiga komponen inti:

  • Validasi Integritas Payload (SHA-256): Menyimpan hash dari request body untuk memverifikasi bahwa retry dari key yang sama tidak membawa payload yang berbeda.
  • Distributed In-Flight Lock: Mengunci eksekusi selama request pertama masih diproses untuk menangani request konkuren dengan key yang sama.
  • Response Cache: Menyimpan status code dan data respon setelah pemrosesan selesai agar dapat di-replay secara deterministik saat retry terjadi.

State transition dari kunci idempotensi berjalan sebagai berikut:

Klien mengirim key → Lock in-flight dipasang via cache atomic write → Handler memproses data → Respon disimpan ke cache → Lock dilepas → Respon dikembalikan.

Implementasi Custom Decorator DRF

Berikut adalah implementasi decorator produksi minimalis menggunakan Django Cache framework (direkomendasikan dengan backend Redis) untuk menangani lifecycle idempotensi:

import hashlib
import json
from functools import wraps
from django.core.cache import cache
from rest_framework import status
from rest_framework.response import Response

def idempotent_endpoint(ttl=86400, lock_timeout=120):
    """
    Memastikan endpoint aman dari retry duplikat.
    - ttl: Durasi cache response (default 24 jam)
    - lock_timeout: Batas waktu maksimal request in-flight (default 2 menit)
    """
    def decorator(view_func):
        @wraps(view_func)
        def wrapper(view_instance, request, *args, **kwargs):
            # 1. Validasi keberadaan header Idempotency-Key
            idempotency_key = request.headers.get("Idempotency-Key")
            if not idempotency_key:
                return Response(
                    {"error": "Header 'Idempotency-Key' wajib disertakan."},
                    status=status.HTTP_400_BAD_REQUEST,
                )

            # Batasi scope per user agar key tidak bertabrakan antar tenant/user
            user_id = getattr(request.user, "id", "anon")
            base_cache_key = f"idempotency:{user_id}:{idempotency_key}"
            lock_key = f"{base_cache_key}:lock"
            data_key = f"{base_cache_key}:response"

            # 2. Hitung SHA-256 hash dari body untuk deteksi mutasi payload
            body_bytes = request.body or b""
            current_payload_hash = hashlib.sha256(body_bytes).hexdigest()

            # 3. Cek respon yang sudah tersimpan (Replay phase)
            cached_data = cache.get(data_key)
            if cached_data:
                if cached_data["payload_hash"] != current_payload_hash:
                    return Response(
                        {
                            "error": "Idempotency-Key sudah digunakan dengan payload berbeda."
                        },
                        status=status.HTTP_422_UNPROCESSABLE_ENTITY,
                    )
                # Replay response
                return Response(
                    cached_data["data"],
                    status=cached_data["status_code"],
                    headers={"X-Cache-Lookup": "HIT - Idempotent Replay"},
                )

            # 4. Atomic locking untuk menangani request konkuren
            # cache.add() mengembalikan True hanya jika key belum pernah ada di storage
            acquired = cache.add(lock_key, "in-flight", timeout=lock_timeout)
            if not acquired:
                return Response(
                    {"error": "Request sedang diproses. Silakan tunggu."},
                    status=status.HTTP_409_CONFLICT,
                )

            try:
                # 5. Eksekusi view logic
                response = view_func(view_instance, request, *args, **kwargs)

                # 6. Simpan respon hanya untuk kode status sukses atau client error deterministik
                if status.is_success(response.status_code):
                    cache.set(
                        data_key,
                        {
                            "status_code": response.status_code,
                            "data": response.data,
                            "payload_hash": current_payload_hash,
                        },
                        timeout=ttl,
                    )
                return response
            finally:
                # 7. Release lock in-flight setelah proses selesai
                cache.delete(lock_key)

        return wrapper
    return decorator

Penerapan pada DRF APIView

Decorator ini dapat dipasang langsung pada method HTTP di class-based view atau viewset action:

from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status

class PaymentView(APIView):
    permission_classes = [IsAuthenticated]

    @idempotent_endpoint(ttl=86400)
    def post(self, request):
        amount = request.data.get("amount")
        order_id = request.data.get("order_id")

        # Simulasi mutasi database/transaksi keuangan
        payment_record = {
            "payment_id": f"pay_{order_id}",
            "order_id": order_id,
            "amount": amount,
            "status": "PAID",
        }
        return Response(payment_record, status=status.HTTP_201_CREATED)

Penanganan Edge Cases

1. Permintaan Konkuren dengan Key Sama (HTTP 409 Conflict)

Ketika klien secara paralel mengirim dua request dengan header Idempotency-Key identik sebelum eksekusi pertama selesai, race condition dapat terjadi. Perintah cache.add() beroperasi secara atomik pada Redis (menggunakan printah dasar SETNX). Jika key sudah ada, pemanggilan kedua langsung ditolak dengan status HTTP 409 Conflict tanpa membebani database.

2. Mismatch Payload (HTTP 422 Unprocessable Entity)

Klien dilarang menggunakan ulang ID yang sama untuk transaksi yang berbeda (misalnya mengubah nominal transfer). SHA-256 mendeteksi perbedaan hash antara payload request saat ini dengan payload transaksi pertama. Status HTTP 422 memberi sinyal eksplisit bahwa format atau konten request tidak valid untuk key tersebut.

3. Kegagalan Server di Tengah Eksekusi (Crash Recovery)

Jika proses worker mati mendadak saat memproses request (misalnya killed by OOM killer), blok finally tidak tereksekusi. Parameter lock_timeout (120 detik) memastikan bahwa lock kedaluwarsa secara otomatis, sehingga klien dapat melakukan retry kembali setelah window waktu terlewati tanpa terjebak selamanya.

Unit Test Verifikasi Idempotensi

Berikut adalah suite pengujian menggunakan APITestCase untuk memverifikasi kontrak di level integrasi:

import json
from django.contrib.auth.models import User
from django.core.cache import cache
from rest_framework import status
from rest_framework.test import APITestCase

class IdempotencyTestCase(APITestCase):
    def setUp(self):
        cache.clear()
        self.user = User.objects.create_user(username="tester", password="pass123")
        self.client.force_authenticate(user=self.user)
        self.url = "/api/v1/payments/"
        self.headers = {"HTTP_IDEMPOTENCY_KEY": "txn-unique-uuid-001"}
        self.payload = {"order_id": "ORD-99", "amount": 500000}

    def test_first_request_success(self):
        response = self.client.post(self.url, self.payload, format="json", **self.headers)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data["status"], "PAID")

    def test_replay_identical_request_returns_cached_response(self):
        # Eksekusi awal
        res1 = self.client.post(self.url, self.payload, format="json", **self.headers)
        self.assertEqual(res1.status_code, status.HTTP_201_CREATED)

        # Retry kedua
        res2 = self.client.post(self.url, self.payload, format="json", **self.headers)
        self.assertEqual(res2.status_code, status.HTTP_201_CREATED)
        self.assertEqual(res2.data, res1.data)
        self.assertEqual(res2.headers.get("X-Cache-Lookup"), "HIT - Idempotent Replay")

    def test_payload_mismatch_returns_422(self):
        # Request pertama dengan nominal 500000
        self.client.post(self.url, self.payload, format="json", **self.headers)

        # Request kedua dengan key sama tapi nominal 750000
        tampered_payload = {"order_id": "ORD-99", "amount": 750000}
        response = self.client.post(self.url, tampered_payload, format="json", **self.headers)
        self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)

    def test_concurrent_lock_returns_409(self):
        # Simulasikan lock yang sedang aktif
        lock_key = f"idempotency:{self.user.id}:txn-unique-uuid-001:lock"
        cache.set(lock_key, "in-flight", timeout=60)

        response = self.client.post(self.url, self.payload, format="json", **self.headers)
        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)

Trade-offs dan Keputusan Arsitektur

  • Redis Cache vs Database Engine: Pendekatan in-memory cache menggunakan Redis sangat cepat dan mendukung auto-TTL tanpa beban vacuum database. Namun, jika instance Redis restart tanpa persistence aktif (AOF/RDB), riwayat idempotency key hilang. Untuk sistem perbankan dengan audit trail mutlak, persistensi record idempotensi ke tabel relasional via transaksi atomic (select_for_update) lebih dianjurkan meski memiliki latensi I/O yang lebih tinggi.
  • Granularitas Scope Key: Menggabungkan user_id ke dalam cache key merupakan langkah isolasi wajib guna mencegah peretasan enumeration key dari satu user yang dapat mengunci request milik user lain.