Anatomi Masalah: Cache Stampede pada Endpoint /.well-known/

Endpoint metadata seperti /.well-known/openid-configuration dan /.well-known/jwks.json adalah target kritis pada arsitektur berbasis token (OAuth2/OIDC). Saat ribuan request memverifikasi signature JWT secara paralel dan cache JWKS kedaluwarsa secara bersamaan, terjadi fenomena cache stampede (thundering herd).

Karakteristik masalah ini mencakup:

  • Cold Start & Key Rotation: Saat service baru deployment atau IdP merotasi public key, ribuan worker mendapati cache kosong dan serempak menghubungi IdP upstream.
  • Lock Contention: Implementasi muteks yang tidak efisien dapat membuat koneksi worker habis menunggu antrean lock, memicu cascading failure.
  • Dependency Timeout: Identity Provider (IdP) menerapkan rate limiting (HTTP 429) atau mengalami crash karena lonjakan beban mendadak, menggagalkan proses validasi token pada seluruh ekosistem backend.

Strategi 1: Distributed Lock Menggunakan Redis

Pola paling umum untuk menahan thundering herd adalah distributed lock: hanya satu thread/worker yang boleh melakukan komputasi ulang atau fetch ke upstream. Worker lain menunggu hasil cache diperbarui.

Implementasi Lock Aman: Set NX PX & Atomic Release Lua

Jangan gunakan SETNX terpisah dari EXPIRE. Gunakan parameter atomik SET key val NX PX ttl. Untuk rilis kunci, gunakan skrip Lua agar worker tidak menghapus lock milik worker lain jika proses fetch melebihi TTL lock.

// Go: Implementasi Single-Flight Lock via Redis
package jwks

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"time"

	"github.com/redis/go-redis/v9"
)

const releaseLockLua = `
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
`

type LockManager struct {
	client *redis.Client
}

func (m *LockManager) Acquire(ctx context.Context, key string, ttl time.Duration) (string, bool, error) {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	token := hex.EncodeToString(b)

	ok, err := m.client.SetNX(ctx, key, token, ttl).Result()
	if err != nil || !ok {
		return "", false, err
	}
	return token, true, nil
}

func (m *LockManager) Release(ctx context.Context, key, token string) error {
	return m.client.Eval(ctx, releaseLockLua, []string{key}, token).Err()
}

Strategi 2: Probabilistic Early Expiration (Algoritma XFetch)

Distributed locking memiliki kelemahan: worker yang tidak mendapatkan lock harus memblokir thread atau melakukan polling. Pendekatan non-blocking yang superior adalah XFetch (Vattani dkk., 2015). Algoritma ini memicu background refresh secara probabilistik sebelum cache benar-benar kedaluwarsa, di mana probabilitas meningkat seiring mendekatnya waktu kedaluwarsa dan tingginya read latency.

Formula komputasi XFetch:

// Refresh jika: -beta * delta * ln(random()) > (ttl - time_remaining)
// delta: waktu komputasi fetch ke IdP (detik)
// beta: agresivitas refresh (> 0, default 1.0)

Implementasi In-Memory Cache dengan Pola XFetch

package jwks

import (
	"context"
	"math"
	"math/rand"
	"sync"
	"time"
)

type CacheItem struct {
	Data      []byte
	ExpiresAt time.Time
	Delta     time.Duration // Durasi fetch upstream terakhir
}

type XFetchCache struct {
	mu    sync.RWMutex
	store map[string]CacheItem
	beta  float64
}

func NewXFetchCache(beta float64) *XFetchCache {
	return &XFetchCache{
		store: make(map[string]CacheItem),
		beta:  beta,
	}
}

func (c *XFetchCache) Get(key string) (CacheItem, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	item, ok := c.store[key]
	return item, ok
}

func (c *XFetchCache) Set(key string, data []byte, ttl, delta time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.store[key] = CacheItem{
		Data:      data,
		ExpiresAt: time.Now().Add(ttl),
		Delta:     delta,
	}
}

// ShouldRefresh menentukan apakah worker harus fetch ulang secara probabilistik
func (c *XFetchCache) ShouldRefresh(item CacheItem) bool {
	now := time.Now()
	if now.After(item.ExpiresAt) {
		return true // Hard expired
	}

	timeRemaining := item.ExpiresAt.Sub(now).Seconds()
	deltaSeconds := item.Delta.Seconds()
	
	// -beta * delta * ln(U)
	r := rand.Float64()
	if r == 0 {
		return true
	}
	probabilityThreshold := -c.beta * deltaSeconds * math.Log(r)
	
	return probabilityThreshold > timeRemaining
}

Strategi 3: Reverse Proxy & Stale-While-Revalidate

Untuk memangkas traffic sebelum menyentuh layer worker aplikasi, posisikan reverse proxy (seperti NGINX, Envoy, atau Cloudflare Worker) di depan endpoint downstream dengan direktif stale-while-revalidate dan stale-if-error.

Konfigurasi NGINX untuk endpoint internal proxy cache JWKS:

proxy_cache_path /var/cache/nginx/jwks levels=1:2 keys_zone=jwks_cache:10m max_size=100m inactive=24h use_temp_path=off;

server {
    listen 8080;

    location /jwks-proxy {
        proxy_pass https://idp.internal.domain/.well-known/jwks.json;
        proxy_cache jwks_cache;
        
        # Cache data valid selama 1 jam
        proxy_cache_valid 200 1h;
        
        # Izinkan respon stale jika data sedang di-fetch di background atau IdP error
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        
        # Pastikan hanya 1 request yang dialirkan ke IdP saat cache expired
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
        
        # Background revalidation
        proxy_cache_background_update on;
        
        proxy_set_header Host idp.internal.domain;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

Pola Multi-Layer Caching yang Direkomendasikan

Gunakan arsitektur pertahanan berlapis (defense in depth):

  1. L1 - In-Memory Go/Node.js/JVM Cache: Validasi kid (Key ID). Jika kid ada, verifikasi token tanpa I/O network. Cek probabilitas refresh via XFetch.
  2. L2 - Distributed Redis: Shared cache lintas replica. Mengurangi beban saat penambahan pod/instance baru (cold replica).
  3. Lock Fallback: Jika kid tidak ditemukan pada L1 dan L2 (indikasi rotasi kunci aktif oleh IdP), peroleh distributed lock via Redis sebelum menembak IdP. Jika gagal memperoleh lock, lakukan sleep 50-100ms dan baca kembali L2 cache.
  4. Stale Cache Fallback: Jika IdP mengembalikan response 5xx atau timeout, gunakan payload kunci JWKS lama sebagai fallback darurat daripada menggagalkan seluruh flow autentikasi.