Pada endpoint POST, masalah utamanya bukan hanya validasi input, tetapi juga duplikasi efek samping: transaksi tercatat dua kali, order ganda, saldo terpotong berulang, atau webhook diproses lebih dari sekali. Go Fiber: kontrak idempotency untuk POST API yang aman berarti server mampu menerima request yang sama lebih dari sekali dan tetap menghasilkan satu efek bisnis yang konsisten.

Solusi yang umum adalah Idempotency-Key: client mengirim kunci unik pada setiap operasi POST yang perlu aman saat retry. Server lalu mengikat kunci itu ke identitas pemanggil, route, fingerprint request, dan hasil respons pertama. Jika request yang sama datang lagi, server mengembalikan hasil yang sama, bukan mengeksekusi operasi baru.

Apa yang sebenarnya ingin dicegah?

Idempotency untuk POST dibutuhkan ketika request bisa terkirim lebih dari sekali karena hal-hal berikut:

  • Client retry otomatis setelah timeout.
  • Gangguan jaringan: server sudah memproses, tetapi client tidak menerima respons.
  • Double submit dari UI, misalnya tombol diklik dua kali.
  • Retry dari job queue atau webhook sender.

Tanpa kontrak yang jelas, retry bisa menimbulkan dua jenis masalah:

  • Duplicate execution: operasi dijalankan ulang dan menciptakan data/transaksi baru.
  • Ambiguous result: client tidak tahu request pertama berhasil atau tidak.

Idempotency tidak sama dengan deduplikasi global. Tujuannya adalah: untuk satu intent bisnis yang sama, request ulang tidak menghasilkan efek baru.

Kontrak API: header, scope, dan perilaku yang harus konsisten

1. Header Idempotency-Key

Gunakan header seperti:

POST /payments
Idempotency-Key: 01HV7M9R3J7F2Y5P8A1NQKZ6XE
Authorization: Bearer ...
Content-Type: application/json

Kunci ini sebaiknya:

  • Dihasilkan oleh client.
  • Unik untuk satu intent bisnis.
  • Tidak dipakai ulang untuk operasi yang berbeda.

Formatnya bebas selama cukup unik, misalnya UUID atau ULID. Server sebaiknya memperlakukan nilainya sebagai string opaque, bukan mencoba menebak maknanya.

2. Scope key: jangan global tanpa konteks

Idempotency-Key jarang aman jika dianggap global. Scope yang lebih tepat biasanya gabungan dari:

  • Identitas pemanggil (misalnya user_id, merchant_id, account_id, atau API key subject).
  • Route atau operasi (misalnya POST /payments berbeda dari POST /orders).

Dengan begitu, key yang sama dari user berbeda tidak saling bertabrakan. Secara konseptual, kunci penyimpanan bisa dibentuk seperti:

scope = user_id + ":" + method + ":" + normalized_route + ":" + idempotency_key

Kenapa route perlu masuk scope? Karena client bisa saja memakai generator key yang sama di beberapa endpoint. Tanpa scope route, request yang berbeda berpotensi dianggap replay yang sama.

3. Request fingerprint: payload sama atau berbeda?

Satu Idempotency-Key harus dikaitkan dengan fingerprint request. Ini penting untuk membedakan dua situasi:

  • Replay yang sah: key sama, payload sama.
  • Pelanggaran kontrak: key sama, payload berbeda.

Fingerprint umumnya dibuat dari data yang relevan secara bisnis, misalnya:

  • HTTP method
  • route
  • user/tenant
  • body request yang sudah dinormalisasi

Jika body JSON dipakai, hati-hati: string mentah bisa berbeda walau isinya sama karena urutan field atau whitespace. Pilihan yang lebih aman adalah melakukan canonicalization sederhana sebelum di-hash, atau membangun fingerprint dari field yang memang penting bagi operasi bisnis.

Jangan masukkan header yang berubah-ubah seperti Date atau token akses ke fingerprint. Itu akan membuat replay sah dianggap berbeda.

Status code yang tepat dan semantik respons

Kontrak idempotency akan lebih mudah diintegrasikan jika perilaku status code konsisten.

Kasus yang umum

  • Request pertama berhasil diproses → kembalikan status normal, misalnya 201 Created atau 200 OK.
  • Replay dengan key dan fingerprint yang sama setelah sukses → kembalikan hasil pertama yang sama. Banyak sistem memilih tetap mengembalikan 200 atau 201 beserta body yang sama. Yang terpenting adalah konsistensi.
  • Key sama, payload berbeda → kembalikan 409 Conflict karena ada konflik terhadap kontrak idempotency.
  • Request kedua datang saat request pertama masih diproses → bisa kembalikan 409 Conflict atau 425 Too Early secara konseptual, tetapi dalam praktik 409 lebih umum dan aman untuk interoperabilitas.
  • Header wajib tetapi tidak dikirim400 Bad Request.

Jika respons replay dikembalikan dari cache/record idempotency, Anda dapat menambahkan header diagnostik seperti:

Idempotency-Status: replay
Idempotency-Replayed: true

Header ini tidak wajib, tetapi membantu debugging client dan observability.

Apa yang harus disimpan?

Minimal, server perlu menyimpan metadata idempotency berikut:

  • scope key
  • fingerprint request
  • status pemrosesan: processing, succeeded, atau failed
  • status code respons awal
  • body respons awal, jika ingin replay respons yang sama
  • reference ke resource/transaksi yang dibuat
  • waktu kedaluwarsa (TTL)

Ada dua strategi utama:

1. Simpan respons penuh

Kelebihan:

  • Paling mudah untuk replay identik.
  • Client menerima hasil yang konsisten.

Kekurangan:

  • Penyimpanan lebih besar.
  • Harus hati-hati jika body mengandung data sensitif.

2. Simpan referensi hasil bisnis

Misalnya simpan payment_id, lalu saat replay ambil resource itu lagi dan bentuk respons baru yang ekuivalen.

Kelebihan:

  • Lebih hemat ruang.
  • Lebih mudah jika respons bisa direkonstruksi secara stabil.

Kekurangan:

  • Perlu memastikan representasi resource tetap kompatibel untuk replay.
  • Bisa lebih kompleks jika respons awal berisi data transient.

Untuk endpoint transaksi kritikal, praktik yang aman adalah menyimpan metadata + referensi resource, dan jika memungkinkan juga body respons yang sudah disanitasi.

TTL: berapa lama record idempotency disimpan?

TTL bergantung pada pola retry client dan risiko bisnis. Prinsip umumnya:

  • Cukup lama untuk menampung retry normal akibat timeout atau jaringan tidak stabil.
  • Tidak terlalu lama sehingga storage penuh atau key lama menghambat operasi baru yang sebenarnya berbeda.

Pada banyak sistem, TTL diset dalam hitungan jam atau hari, bukan permanen. Untuk operasi keuangan, TTL sering dibuat lebih konservatif karena dampak duplikasi lebih mahal daripada biaya penyimpanan.

Hal penting: TTL adalah bagian kontrak. Jika key sudah kedaluwarsa, request dengan key yang sama dapat dianggap request baru. Ini harus dipahami oleh tim client.

Race condition: inti masalah di implementasi nyata

Masalah tersulit bukan menyimpan key, tetapi mencegah dua request paralel dengan key sama sama-sama lolos ke logika bisnis.

Pola yang aman

  1. Terima request dan validasi header Idempotency-Key.
  2. Bangun scope dan fingerprint.
  3. Lakukan claim atomik atas key: jika belum ada, tandai sebagai processing.
  4. Jika sudah ada record:
    • fingerprint beda → 409 Conflict
    • status processing → tolak atau minta retry
    • status succeeded → replay hasil lama
  5. Jalankan logika bisnis.
  6. Simpan hasil akhir ke record idempotency.

Kata kuncinya adalah atomik. Jangan cek "sudah ada atau belum" lalu insert di langkah terpisah tanpa proteksi, karena dua request paralel bisa sama-sama melihat data belum ada.

Opsi penyimpanan

Redis cocok untuk claim atomik cepat dengan TTL. Database relasional cocok jika Anda ingin konsistensi kuat dan bisa menyatukan record idempotency dengan transaksi bisnis.

Contoh skema Redis dan tabel database

Skema Redis

Satu key per operasi:

idem:{user_id}:{method}:{route}:{idempotency_key}

Nilai bisa berupa JSON ringkas:

{
  "fingerprint": "sha256:...",
  "status": "processing",
  "status_code": 0,
  "response_body": "",
  "resource_id": "",
  "created_at": "2026-06-17T10:00:00Z"
}

Pola umum:

  • Claim awal dengan operasi atomik set-if-not-exists dan TTL.
  • Finalisasi dengan update nilai ke succeeded dan memperpanjang TTL sesuai kebijakan.

Skema tabel SQL

CREATE TABLE api_idempotency (
  scope_key        VARCHAR(255) PRIMARY KEY,
  user_id          VARCHAR(64) NOT NULL,
  route            VARCHAR(128) NOT NULL,
  method           VARCHAR(8) NOT NULL,
  idempotency_key  VARCHAR(128) NOT NULL,
  fingerprint      VARCHAR(128) NOT NULL,
  status           VARCHAR(16) NOT NULL,
  status_code      INTEGER,
  response_body    TEXT,
  resource_id      VARCHAR(64),
  created_at       TIMESTAMP NOT NULL,
  updated_at       TIMESTAMP NOT NULL,
  expires_at       TIMESTAMP NOT NULL
);

Jika tidak memakai scope_key sebagai primary key, minimal buat unique index pada kombinasi yang menjadi scope.

Di database relasional, unique constraint adalah fondasi untuk mencegah duplikasi claim.

Contoh alur request-response

Alur sukses pertama kali

  1. Client kirim POST /payments dengan Idempotency-Key: K1.
  2. Server membuat scope dan fingerprint.
  3. Record K1 belum ada, server claim status processing.
  4. Server membuat payment pay_123.
  5. Server menyimpan hasil: succeeded, status code 201, resource_id pay_123.
  6. Client menerima 201 Created.

Alur replay setelah timeout di client

  1. Request pertama sebenarnya sukses, tetapi respons hilang di jaringan.
  2. Client retry dengan Idempotency-Key: K1 dan payload sama.
  3. Server menemukan record succeeded dengan fingerprint yang sama.
  4. Server mengembalikan hasil lama, tanpa membuat payment baru.

Alur key sama, payload berbeda

  1. Client kirim K1 dengan amount 100000.
  2. Lalu kirim lagi K1 dengan amount 150000.
  3. Fingerprint berbeda.
  4. Server kembalikan 409 Conflict.

Alur request paralel

  1. Dua request identik dengan K1 masuk hampir bersamaan.
  2. Hanya satu request berhasil claim key.
  3. Request lain melihat status processing.
  4. Server mengembalikan 409 Conflict atau respons retryable sesuai kontrak.

Implementasi praktis di Go Fiber

Di bawah ini contoh implementasi ringkas dengan pendekatan service-level. Contoh ini sengaja fokus pada kontrak dan alur, bukan pada driver Redis/DB tertentu.

Model record idempotency

type IdempotencyRecord struct {
    ScopeKey     string
    Fingerprint  string
    Status       string // processing, succeeded, failed
    StatusCode   int
    ResponseBody []byte
    ResourceID   string
}

type IdempotencyStore interface {
    Claim(scopeKey, fingerprint string, ttlSeconds int) (claimed bool, rec *IdempotencyRecord, err error)
    MarkSucceeded(scopeKey string, statusCode int, body []byte, resourceID string, ttlSeconds int) error
    MarkFailed(scopeKey string, statusCode int, body []byte, ttlSeconds int) error
    Get(scopeKey string) (*IdempotencyRecord, error)
}

Helper untuk scope dan fingerprint

func buildScopeKey(userID, method, route, idemKey string) string {
    return userID + ":" + method + ":" + route + ":" + idemKey
}

func fingerprint(method, route, userID string, body []byte) string {
    h := sha256.New()
    h.Write([]byte(method))
    h.Write([]byte("|"))
    h.Write([]byte(route))
    h.Write([]byte("|"))
    h.Write([]byte(userID))
    h.Write([]byte("|"))
    h.Write(body)
    return hex.EncodeToString(h.Sum(nil))
}

Di sistem produksi, pertimbangkan normalisasi JSON sebelum hashing jika body JSON dapat memiliki variasi format.

Handler Fiber untuk POST yang idempotent

func CreatePaymentHandler(store IdempotencyStore) fiber.Handler {
    return func(c *fiber.Ctx) error {
        idemKey := c.Get("Idempotency-Key")
        if idemKey == "" {
            return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
                "error": "missing Idempotency-Key",
            })
        }

        userID := c.Locals("user_id").(string)
        route := "/payments"
        body := c.Body()

        fp := fingerprint(c.Method(), route, userID, body)
        scope := buildScopeKey(userID, c.Method(), route, idemKey)

        claimed, rec, err := store.Claim(scope, fp, 300)
        if err != nil {
            return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
                "error": "idempotency store error",
            })
        }

        if !claimed {
            if rec.Fingerprint != fp {
                return c.Status(fiber.StatusConflict).JSON(fiber.Map{
                    "error": "idempotency key already used with different payload",
                })
            }

            switch rec.Status {
            case "processing":
                return c.Status(fiber.StatusConflict).JSON(fiber.Map{
                    "error": "request with same idempotency key is still processing",
                })
            case "succeeded", "failed":
                c.Set("Idempotency-Status", "replay")
                return c.Status(rec.StatusCode).Send(rec.ResponseBody)
            default:
                return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
                    "error": "invalid idempotency state",
                })
            }
        }

        // Validasi payload bisnis
        var req struct {
            Amount   int64  `json:"amount"`
            Currency string `json:"currency"`
        }
        if err := c.BodyParser(&req); err != nil {
            resp := []byte(`{"error":"invalid json"}`)
            _ = store.MarkFailed(scope, fiber.StatusBadRequest, resp, 300)
            return c.Status(fiber.StatusBadRequest).Send(resp)
        }

        // Logika bisnis harus sebisa mungkin atomik dengan persistence utama.
        paymentID, err := createPaymentInDB(userID, req.Amount, req.Currency)
        if err != nil {
            resp := []byte(`{"error":"unable to create payment"}`)
            _ = store.MarkFailed(scope, fiber.StatusInternalServerError, resp, 300)
            return c.Status(fiber.StatusInternalServerError).Send(resp)
        }

        resp := []byte(fmt.Sprintf(`{"id":"%s","status":"created"}`, paymentID))
        if err := store.MarkSucceeded(scope, fiber.StatusCreated, resp, paymentID, 86400); err != nil {
            // Operasi bisnis sudah sukses, tetapi pencatatan idempotency gagal.
            // Jangan diamkan: log dan observability wajib ada.
        }

        return c.Status(fiber.StatusCreated).Send(resp)
    }
}

Contoh di atas menunjukkan beberapa prinsip penting:

  • Header wajib divalidasi di awal.
  • Claim dilakukan sebelum logika bisnis.
  • Replay hanya sah jika fingerprint sama.
  • Hasil pertama disimpan dan bisa diputar ulang.

Middleware atau handler?

Untuk Go Fiber, idempotency bisa ditempatkan di middleware, tetapi sering kali lebih praktis dikerjakan di level handler/service karena:

  • Fingerprint kadang butuh pengetahuan payload bisnis.
  • Hasil yang disimpan bisa berupa resource_id atau body yang dibentuk handler.
  • Integrasi dengan transaksi database lebih mudah dikontrol dari service layer.

Middleware tetap berguna untuk validasi dasar, ekstraksi header, logging, atau menaruh konteks idempotency ke Locals. Namun keputusan final tentang apa yang dianggap “request yang sama” biasanya lebih aman ditaruh dekat logika domain.

Kasus sulit: kegagalan parsial dan transaksi bisnis

Edge case paling berbahaya adalah operasi bisnis sukses, tetapi penyimpanan record idempotency gagal. Misalnya payment berhasil dibuat di database, tetapi Redis sedang tidak tersedia saat MarkSucceeded.

Akibatnya, retry berikutnya bisa terlihat seperti request baru dan menciptakan duplikasi.

Cara mengurangi risiko

  • Prioritaskan penyimpanan idempotency di database yang sama dengan transaksi bisnis jika memungkinkan.
  • Jika memakai database relasional, pertimbangkan satu transaksi yang mencakup pembuatan resource dan finalisasi record idempotency.
  • Jika memakai Redis terpisah, siapkan reconciliation atau cek berbasis business key tambahan.

Business key sebagai lapisan kedua

Untuk operasi kritikal, jangan hanya mengandalkan Idempotency-Key. Pertimbangkan juga unique constraint di level domain, misalnya:

  • external_reference harus unik per merchant
  • order_number harus unik per tenant

Dengan begitu, jika lapisan idempotency gagal, constraint bisnis masih dapat menahan duplikasi.

Payload sama vs berbeda: definisi harus eksplisit

Pertanyaan yang sering muncul: apa arti “payload sama”?

Jawabannya tergantung operasi. Untuk endpoint pembayaran, field berikut biasanya dianggap material:

  • amount
  • currency
  • destination/source account
  • external reference

Sedangkan field seperti header tracing atau metadata non-bisnis mungkin tidak perlu memengaruhi fingerprint. Jangan asal hash seluruh request tanpa berpikir; definisikan field mana yang menentukan intent bisnis.

Replay setelah sukses: kembalikan hasil lama, bukan hasil terbaru yang berubah

Jika request pertama sukses dan resource kemudian berubah status oleh proses lain, replay idempotency idealnya mengembalikan hasil awal yang terkait dengan operasi tersebut, bukan semata-mata state terbaru yang bisa berbeda konteks.

Contoh: payment dibuat dengan status pending, lalu beberapa detik kemudian berubah menjadi settled oleh worker async. Jika replay terjadi segera setelah pembuatan, sering kali lebih masuk akal mengembalikan hasil pembuatan awal yang sama dengan request pertama.

Trade-off-nya:

  • Simpan respons awal → replay lebih konsisten terhadap kontrak.
  • Rebuild dari resource terbaru → lebih sederhana, tetapi bisa membingungkan client.

Pilih salah satu dan dokumentasikan dengan jelas.

Observability dan debugging

Tanpa observability, bug idempotency sulit dilacak karena biasanya muncul saat timeout, retry, dan kondisi paralel.

Minimal catat:

  • idempotency_key
  • scope_key
  • fingerprint
  • status record saat request masuk
  • hasil claim: claimed atau replay
  • resource_id yang dibuat

Tambahkan metrik seperti:

  • jumlah replay sukses
  • jumlah conflict karena fingerprint berbeda
  • jumlah request yang mentok di status processing
  • kegagalan finalisasi record idempotency

Jika ada record processing yang tidak pernah selesai karena crash, siapkan strategi pemulihan. Misalnya, status processing kedaluwarsa setelah waktu tertentu dan request berikutnya akan melakukan evaluasi ulang dengan hati-hati.

Kesalahan umum integrasi

  • Menganggap idempotency key global tanpa scope user/route.
  • Tidak menyimpan fingerprint, sehingga key yang sama dengan payload berbeda tetap dianggap replay sah.
  • Cek lalu insert tanpa atomisitas, yang membuka race condition.
  • Hanya menyimpan key, bukan hasil, sehingga replay tidak bisa mengembalikan respons yang konsisten.
  • TTL terlalu pendek, sehingga retry normal setelah timeout malah diperlakukan sebagai request baru.
  • Tidak memikirkan kegagalan parsial antara operasi bisnis dan penyimpanan record idempotency.
  • Menghash body mentah JSON tanpa normalisasi saat variasi format mungkin terjadi.
  • Mengembalikan status code yang berubah-ubah sehingga client sulit membedakan replay, conflict, dan request baru.

Checklist implementasi

  1. Tentukan endpoint POST mana yang wajib memakai Idempotency-Key.
  2. Definisikan format header dan dokumentasikan bahwa key dibuat oleh client.
  3. Tentukan scope: minimal user/tenant + method + route + key.
  4. Tentukan fingerprint berdasarkan field bisnis yang material.
  5. Implementasikan claim atomik untuk mencegah dua request paralel lolos bersamaan.
  6. Simpan status processing, succeeded, dan jika perlu failed.
  7. Simpan status code dan body respons awal, atau referensi resource yang cukup untuk replay.
  8. Tetapkan aturan 409 Conflict untuk key sama dengan payload berbeda.
  9. Tentukan TTL yang sesuai dengan pola retry dan risiko bisnis.
  10. Rancang penanganan kegagalan parsial antara persistence utama dan store idempotency.
  11. Tambahkan logging, tracing, dan metrik replay/conflict.
  12. Uji skenario: retry setelah timeout, double submit, request paralel, payload berbeda, dan replay setelah sukses.

Penutup

Go Fiber: kontrak idempotency untuk POST API yang aman bukan sekadar menambahkan header, tetapi merancang perilaku server yang konsisten saat request diulang. Kunci keberhasilannya ada pada empat hal: scope yang benar, fingerprint request, penyimpanan hasil awal, dan claim atomik untuk mencegah race condition.

Jika endpoint Anda membuat transaksi, order, atau perubahan state yang mahal untuk dibatalkan, idempotency sebaiknya dianggap bagian dari kontrak API inti, bukan fitur tambahan. Dokumentasikan perilakunya dengan jelas, uji pada kondisi retry dan timeout, lalu pastikan implementasinya tahan terhadap kegagalan parsial.