Tantangan Retry Ganda pada API Vision AI

Inferensi Computer Vision (seperti deteksi penyakit tanaman pada GrassDx) memerlukan beban komputasi GPU yang tinggi dan transfer payload gambar berukuran besar. Masalah utama muncul ketika client mengalami network timeout saat transmisi HTTP belum selesai atau saat model masih memproses inferensi.

Ketika client melakukan retry otomatis, permintaan yang sama dapat dieksekusi berkali-kali. Dampaknya meliputi:

  • Pemborosan Biaya Komputasi GPU: Model inferensi memproses tensor gambar identik lebih dari satu kali.
  • Race Condition Database: Record diagnosa baru dibuat ganda untuk objek fisik yang sama.
  • Exhaustion Worker Pool: Antrean job inferensi penuh oleh task redundan, meningkatkan latensi sistem secara keseluruhan.

Solusi standar industri untuk masalah ini adalah menerapkan Idempotency Key menggunakan spesifikasi IETF draft, dipadukan dengan validasi fingerprint hash payload dan status tracking terdistribusi.

Arsitektur Kontrak API dan Spesifikasi Header IETF

Client mengirimkan token idempotensi unik pada header HTTP. Standar draft IETF menggunakan header Idempotency-Key. Server menggunakan key ini bersama SHA-256 dari biner gambar untuk memverifikasi keaslian request.

Spesifikasi Header HTTP Request

POST /v1/diagnostics/grass HTTP/1.1
Host: api.grassdx.internal
Idempotency-Key: 7b84a9e2-51c3-4d6d-bf22-e4231b519cb8
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXYZ
X-Signature: t=1710576000,v1=a8f5c...

Skema Payload Request (Multipart Form)

  • image: File biner citra daun (format JPEG/PNG).
  • crop_type: String (contoh: bermudagrass).
  • callback_url: URL endpoint webhook client.

Response Task Awal (HTTP 202 Accepted)

Untuk menghindari client blocking dan timeout HTTP pada inferensi berdurasi panjang, endpoint mengembalikan status 202 Accepted segera setelah task dijadwalkan.

HTTP/1.1 202 Accepted
Content-Type: application/json
Idempotency-Key: 7b84a9e2-51c3-4d6d-bf22-e4231b519cb8

{
  "task_id": "tsk_9021841284",
  "status": "PENDING",
  "created_at": "2025-05-10T08:30:00Z",
  "fingerprint": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

State Machine Task dan Concurrency Control di Redis

Sistem backend mengelola siklus hidup task di Redis menggunakan tiga state: PENDING, PROCESSING, dan COMPLETED. Distributed lock menggunakan SET resource_name my_random_value NX EX max_lock_time digunakan untuk mencegah eksekusi konkuren.

Transisi State Task

  1. PENDING: Task tercatat, lock berhasil diperoleh, job dimasukkan ke queue GPU.
  2. PROCESSING: GPU worker mengambil task dari queue dan mulai memproses model segmentasi/klasifikasi.
  3. COMPLETED: Inferensi selesai, hasil disimpan permanen, webhook dipicu, data cache idempotensi diperbarui dengan hasil akhir.

Pseudocode Penanganan Request & Race Condition

import hashlib
import json

def handle_diagnosis_request(request):
    idempotency_key = request.headers.get("Idempotency-Key")
    if not idempotency_key:
        return {"error": "Missing Idempotency-Key header"}, 400

    # 1. Hitung SHA-256 dari biner gambar untuk payload integrity check
    image_bytes = request.files["image"].read()
    current_fingerprint = hashlib.sha256(image_bytes).hexdigest()
    
    redis_key = f"idempotency:{idempotency_key}"
    lock_key = f"lock:{idempotency_key}"
    
    # 2. Cek apakah key sudah pernah diproses sebelumnya
    existing_record = redis_client.get(redis_key)
    if existing_record:
        record = json.loads(existing_record)
        
        # Tolak jika key sama digunakan untuk payload berbeda
        if record["fingerprint"] != current_fingerprint:
            return {"error": "Idempotency-Key conflict: payload mismatch"}, 422
            
        # Kembalikan response tersimpan sesuai status saat ini
        if record["status"] in ["PENDING", "PROCESSING"]:
            return record["response_payload"], 202
        elif record["status"] == "COMPLETED":
            return record["response_payload"], 200

    # 3. Dapatkan distributed lock (TTL 60 detik)
    acquired = redis_client.set(lock_key, "locked", nx=True, ex=60)
    if not acquired:
        # Conflict race-condition: request paralel kedua saat request pertama sedang acquire
        return {"error": "Concurrent request in progress, retry shortly"}, 409

    try:
        task_id = generate_ulid()
        initial_response = {
            "task_id": task_id,
            "status": "PENDING",
            "fingerprint": current_fingerprint
        }
        
        # 4. Simpan state awal dengan TTL (contoh: 24 jam = 86400 detik)
        task_state = {
            "fingerprint": current_fingerprint,
            "status": "PENDING",
            "response_payload": initial_response
        }
        redis_client.set(redis_key, json.dumps(task_state), ex=86400)
        
        # 5. Push ke queue worker AI
        queue_payload = {
            "task_id": task_id,
            "idempotency_key": idempotency_key,
            "image_bytes": image_bytes,
            "callback_url": request.form.get("callback_url")
        }
        gpu_task_queue.push(queue_payload)
        
        return initial_response, 202
    finally:
        # Lepas lock distributed
        redis_client.delete(lock_key)

Pengiriman Hasil Inferensi via Webhook Terverifikasi HMAC

Setelah worker GPU menyelesaikan eksekusi model, server mengirimkan HTTP POST callback ke callback_url client. Untuk mencegah man-in-the-middle (MITM) dan memastikan keaslian callback, server menandatangani body payload menggunakan HMAC-SHA256.

Format Payload Callback

{
  "event": "diagnosis.completed",
  "task_id": "tsk_9021841284",
  "idempotency_key": "7b84a9e2-51c3-4d6d-bf22-e4231b519cb8",
  "data": {
    "disease_detected": "Bipolaris cynodontis (Leaf Blight)",
    "confidence": 0.942,
    "severity": "MODERATE",
    "bounding_box": {
      "x": 120,
      "y": 85,
      "width": 340,
      "height": 410
    }
  },
  "processed_at": 1710576045
}

Header Autentikasi Webhook

Header webhook menyertakan timestamp unix dan signature hexadecimal:

X-Signature: t=1710576045,v1=e7c8e9d50672b1d3d63d66420f5c6b6d510bf8a08a3d5821f57f58b09f428c0b

Verifikasi Signature pada Sisi Client (Python)

import hmac
import hashlib
import time

def verify_webhook(raw_payload: bytes, signature_header: str, secret_key: str) -> bool:
    parts = dict(x.split("=") for x in signature_header.split(","))
    timestamp = parts.get("t")
    received_sig = parts.get("v1")
    
    if not timestamp or not received_sig:
        return False
        
    # Toleransi timestamp 5 menit (300 detik) untuk cegah replay attacks
    if abs(time.time() - int(timestamp)) > 300:
        return False
        
    signed_payload = f"{timestamp}.".encode("utf-8") + raw_payload
    computed_sig = hmac.new(
        secret_key.encode("utf-8"),
        signed_payload,
        hashlib.sha256
    ).hexdigest()
    
    # Mitigasi timing attack dengan compare_digest
    return hmac.compare_digest(computed_sig, received_sig)

Troubleshooting dan Edge Cases

  • Payload Mismatch (HTTP 422): Terjadi jika client mengirim ulang Idempotency-Key yang sama namun dengan file gambar atau parameter deteksi berbeda. Server wajib memvalidasi hash SHA-256 dan menolak request dengan status 422 Unprocessable Entity.
  • Worker Crash Saat PROCESSING: Jika GPU worker mengalami Out-of-Memory (OOM), task dapat tertahan di state PROCESSING. Atasi dengan menerapkan Dead Man's Switch atau worker heartbeat TTL pada Redis. Jika TTL habis tanpa update, ubah status menjadi FAILED dan izinkan retry.
  • Client Polling Fallback: Selalu sediakan endpoint GET /v1/diagnostics/grass/{idempotency_key} bagi client yang tidak memiliki public IP untuk webhook callback.