Akar Masalah: Concurrency dan Network Retries
Pada arsitektur microservices dan API pembayaran, gangguan jaringan sementara (transient network failure) sering memicu mekanisme auto-retry dari HTTP client. Masalah timbul saat request pertama masih dieksekusi oleh server, namun timeout di sisi client memicu pengiriman request duplikat dengan payload yang sama.
Jika endpoint mutasi seperti POST /payments atau POST /orders tidak bersifat idempoten, server berisiko mengeksekusi dua mutasi finansial atau membuat dua data transaksi identik secara paralel. Pengecekan database konvensional (misalnya memeriksa keberadaan order ID via SELECT sebelum INSERT) memiliki celah waktu (time-of-check to time-of-use / TOCTOU) yang memicu race condition saat kedua request berjalan bersamaan di goroutine terpisah.
Mekanisme State Machine dengan Redis SETNX
Kunci solusi idempotensi terdistribusi adalah operasi atomik tunggal yang bertindak sebagai distributed lock sekaligus response cache. Perintah Redis SET key value NX EX ttl menyediakan jaminan bahwa hanya ada satu request yang berhasil mengunci kunci tersebut pada satuan waktu tertentu.
Siklus hidup idempotensi dibagi ke dalam tiga status utama:
- IN_FLIGHT: Request sedang diproses. Request identik lain yang datang bersamaan harus ditolak dengan status
HTTP 409 Conflictuntuk mencegah eksekusi ganda paralel. - COMPLETED: Request selesai. Status HTTP, header penting, dan body response disimpan di Redis. Request ulangan dengan key yang sama akan menerima salinan response ini (replay) tanpa mengeksekusi ulang logic bisnis.
- VALIDATION_FAILED: Jika klien mengirimkan
Idempotency-Keyyang identik namun payload body berbeda, server harus menolak denganHTTP 422 Unprocessable Entityguna mencegah payload tampering atau tabrakan ID secara tidak sengaja.
Implementasi Middleware Idempotency di Go Fiber
Implementasi di bawah menggunakan driver github.com/redis/go-redis/v9 dan github.com/gofiber/fiber/v2. Skema ini mengkalkulasi SHA-256 dari request body untuk memvalidasi integritas payload, serta menyimpan metadata eksekusi ke Redis.
package middleware
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
type IdempotencyRecord struct {
Status string `json:"status"` // "IN_FLIGHT" atau "COMPLETED"
BodyHash string `json:"body_hash"`
StatusCode int `json:"status_code,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Response string `json:"response,omitempty"`
}
type Config struct {
RedisClient *redis.Client
LockTTL time.Duration
ResultTTL time.Duration
}
func NewIdempotency(cfg Config) fiber.Handler {
return func(c *fiber.Ctx) error {
// Batasi hanya untuk method mutasi
if c.Method() != fiber.MethodPost && c.Method() != fiber.MethodPut && c.Method() != fiber.MethodPatch {
return c.Next()
}
key := c.Get("Idempotency-Key")
if key == "" {
return c.Next()
}
ctx := context.Background()
redisKey := "idemp:" + key
// Amankan body buffer dari lifecycle fasthttp
reqBody := append([]byte(nil), c.Body()...)
hashBytes := sha256.Sum256(reqBody)
currentHash := hex.EncodeToString(hashBytes[:])
inFlightRecord := IdempotencyRecord{
Status: "IN_FLIGHT",
BodyHash: currentHash,
}
rawLock, _ := json.Marshal(inFlightRecord)
// Atomic SET NX EX untuk distributed lock
acquired, err := cfg.RedisClient.SetNX(ctx, redisKey, rawLock, cfg.LockTTL).Result()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Cache failure"})
}
if !acquired {
// Key sudah ada di Redis: ambil data yang tersimpan
val, err := cfg.RedisClient.Get(ctx, redisKey).Result()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Cache read error"})
}
var existing IdempotencyRecord
if err := json.Unmarshal([]byte(val), &existing); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Corrupted state"})
}
// Validasi payload mismatch
if existing.BodyHash != currentHash {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"error": "Idempotency-Key reuse with different payload",
})
}
// Tangani request paralel yang masih berjalan
if existing.Status == "IN_FLIGHT" {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": "Concurrent request in flight",
})
}
// Replay cached response
for hKey, hVal := range existing.Headers {
c.Set(hKey, hVal)
}
c.Set("X-Cache-Lookup", "HIT")
return c.Status(existing.StatusCode).SendString(existing.Response)
}
// Eksekusi downstream handler
execErr := c.Next()
// Jika server mengalami internal crash/5xx, lepaskan lock agar client bisa retry
if execErr != nil || c.Response().StatusCode() >= 500 {
cfg.RedisClient.Del(ctx, redisKey)
return execErr
}
// Amankan buffer response fasthttp sebelum request scope ditutup
respBody := append([]byte(nil), c.Response().Body()...)
capturedHeaders := make(map[string]string)
capturedHeaders[fiber.HeaderContentType] = string(c.Response().Header.ContentType())
completedRecord := IdempotencyRecord{
Status: "COMPLETED",
BodyHash: currentHash,
StatusCode: c.Response().StatusCode(),
Headers: capturedHeaders,
Response: string(respBody),
}
rawComplete, _ := json.Marshal(completedRecord)
cfg.RedisClient.Set(ctx, redisKey, rawComplete, cfg.ResultTTL)
return nil
}
}Menghindari Perangkap Memory Buffering Fasthttp
Go Fiber berjalan di atas engine fasthttp yang menggunakan alokasi zero-copy buffer pool untuk efisiensi CPU dan RAM. Buffer c.Body() dan c.Response().Body() akan di-recycle ke dalam pool segera setelah context request selesai.
Jika slice byte dari fasthttp disimpan langsung ke goroutine asynchronous atau dibiarkan tanpa disalin via append([]byte(nil), c.Body()...) atau bytes.Clone, pointer akan menunjuk ke memori yang sudah ditimpa oleh request HTTP lain. Hal ini menyebabkan silent data corruption pada hash SHA-256 dan response payload yang di-cache.
Pengujian Konkurensi: Race Condition & Replay
Kode unit test berikut memverifikasi tiga skenario konkurensi secara deterministik menggunakan app.Test Fiber dan sync.WaitGroup:
package middleware_test
import (
"bytes"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"yourmodule/middleware"
)
func TestIdempotencyConcurrency(t *testing.T) {
s := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
app := fiber.New()
app.Use(middleware.NewIdempotency(middleware.Config{
RedisClient: rdb,
LockTTL: 5 * time.Second,
ResultTTL: 1 * time.Hour,
}))
app.Post("/process", func(c *fiber.Ctx) error {
time.Sleep(100 * time.Millisecond) // Simulasi kalkulasi database
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "paid"})
})
var wg sync.WaitGroup
results := make(chan int, 2)
payload := []byte(`{"order_id":"ord_123","amount":50000}`)
// Eksekusi dua request paralel dengan key dan payload identik
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := httptest.NewRequest(http.MethodPost, "/process", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "trx-unique-999")
resp, err := app.Test(req, -1)
if err != nil {
t.Errorf("Request failed: %v", err)
return
}
results <- resp.StatusCode
}()
}
wg.Wait()
close(results)
var codes []int
for code := range results {
codes = append(codes, code)
}
// Validasi: satu harus lolos (201 Created), satu harus tertahan (409 Conflict)
has201 := false
has409 := false
for _, code := range codes {
if code == fiber.StatusCreated {
has201 = true
}
if code == fiber.StatusConflict {
has409 = true
}
}
if !has201 || !has409 {
t.Fatalf("Expected 201 and 409 concurrently, got: %v", codes)
}
// Uji skenario replay setelah eksekusi selesai
s.FastForward(200 * time.Millisecond)
replayReq := httptest.NewRequest(http.MethodPost, "/process", bytes.NewReader(payload))
replayReq.Header.Set("Content-Type", "application/json")
replayReq.Header.Set("Idempotency-Key", "trx-unique-999")
replayResp, _ := app.Test(replayReq, -1)
if replayResp.StatusCode != fiber.StatusCreated {
t.Fatalf("Expected cached 201 Created on replay, got: %d", replayResp.StatusCode)
}
// Uji skenario payload mismatch (422)
diffPayload := []byte(`{"order_id":"ord_123","amount":99999}`)
mismatchReq := httptest.NewRequest(http.MethodPost, "/process", bytes.NewReader(diffPayload))
mismatchReq.Header.Set("Content-Type", "application/json")
mismatchReq.Header.Set("Idempotency-Key", "trx-unique-999")
mismatchResp, _ := app.Test(mismatchReq, -1)
if mismatchResp.StatusCode != fiber.StatusUnprocessableEntity {
t.Fatalf("Expected 422 Unprocessable Entity, got: %d", mismatchResp.StatusCode)
}
}Trade-off dan Pertimbangan Produksi
Meskipun Redis SETNX sangat cepat (sub-millisecond latency), terdapat beberapa trade-off yang harus diantisipasi:
- Lock TTL vs Long-running Transaction:
LockTTLharus lebih besar dari timeout HTTP downstream handler. Jika server memproses transaksi selama 5 detik namun TTL diset 3 detik, kunci akan expired sebelum selesai, membuka kembali celah race condition. - Redis Memory Footprint: Menyimpan seluruh body response di Redis dapat menghabiskan RAM jika payload berukuran besar. Solusinya, batasi caching hanya untuk metadata esensial atau gunakan kompresi snappy/gzip sebelum menulis ke Redis jika payload melebihi beberapa kilobyte.
- Handling Server Failure (5xx): Pada implementasi di atas, jika handler mengembalikan error atau status 5xx, Redis key dihapus (
cfg.RedisClient.Del) agar client dapat melakukan retry ulang tanpa terblokir status stale lock.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!