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 Conflict untuk 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-Key yang identik namun payload body berbeda, server harus menolak dengan HTTP 422 Unprocessable Entity guna 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: LockTTL harus 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.