Echo Chamber #

Dua service yang saling memanggil satu sama lain. Terdengar sederhana dan mungkin seperti desain yang masuk akal — Service A memanggil Service B untuk data yang dibutuhkan, dan Service B memanggil Service A untuk validasi. Tapi dalam kondisi tertentu, pola ini bisa berubah menjadi siklus request yang tidak berakhir: A memanggil B, B memanggil A, A memanggil B lagi, dan seterusnya — sampai salah satu service kehabisan memory, stack overflow, atau koneksi database habis.

Inilah yang dimaksud dengan echo chamber dalam konteks API: kondisi di mana dua atau lebih service terjebak dalam siklus request yang saling memicu satu sama lain tanpa ada yang bisa keluar dari siklus tersebut. Satu request masuk menjadi ribuan request yang saling berputar, konsumsi resource melonjak secara eksponensial, dan sistem crash bukan karena satu service bermasalah — tapi karena dua service yang masing-masing “benar” saling menghancurkan satu sama lain.

Bagaimana Echo Chamber Terbentuk #

Echo chamber API biasanya tidak terjadi karena ada yang sengaja membuat siklus. Ia terbentuk dari akumulasi keputusan desain yang masing-masing terlihat masuk akal, tapi menghasilkan coupling sirkuler ketika digabungkan.

sequenceDiagram
    participant C as Client
    participant A as Service A
    participant B as Service B

    C->>A: POST /orders {user_id: 42}
    A->>B: GET /users/42 (validasi user)
    B->>A: GET /orders?user_id=42 (cek order history)
    A->>B: GET /users/42 (validasi user lagi)
    B->>A: GET /orders?user_id=42 (cek order history lagi)
    Note over A,B: Loop tidak berakhir...
    A->>A: Stack overflow / timeout
Skenario umum yang memicu echo chamber:

  Skenario 1: Mutual validation
  Service A validasi request → panggil Service B untuk cek izin
  Service B validasi izin → panggil Service A untuk cek resource yang diminta
  → A memanggil B memanggil A memanggil B...

  Skenario 2: Event yang memicu event yang sama
  Service A update user → publish event "user.updated"
  Service B consume event → update profil → trigger sync kembali ke A
  Service A terima sync → update user → publish event "user.updated" lagi
  → Event storm: ribuan event dalam detik

  Skenario 3: Webhook yang saling trigger
  Payment gateway kirim webhook ke Service A (payment confirmed)
  Service A update order → kirim notifikasi ke Service B
  Service B update status → panggil payment gateway untuk konfirmasi
  Payment gateway kirim webhook lagi...
  → Webhook storm

  Skenario 4: Cache invalidation berantai
  Service A invalidasi cache → notify Service B
  Service B refresh data → panggil Service A
  Service A generate data → invalidasi cache lagi
  → Invalidation loop

Mengapa Echo Chamber Berbahaya #

Yang membuat echo chamber berbeda dari bug biasa adalah sifat eksponensialnya. Satu request dari user bisa menghasilkan ribuan request internal dalam hitungan detik.

Gambaran dampak eksponensial:

  1 request dari user
  → A memanggil B (1 request)
  → B memanggil A (1 request)
  → A memanggil B (1 request)
  → ... (jika tidak ada batas, terus berlanjut)

  Dengan timeout 30 detik dan rata-rata 10ms per call:
  → Bisa terjadi 3.000 putaran sebelum timeout
  → 3.000 request ke database dari Service A
  → 3.000 request ke database dari Service B
  → 6.000 database connections untuk satu request user

  Jika 10 user request bersamaan:
  → 60.000 database connections
  → Connection pool kehabisan
  → Seluruh sistem tidak responsif

  Dan ini bisa terjadi dalam < 30 detik.

Deteksi: Bagaimana Menemukan Echo Chamber #

Mendeteksi echo chamber bisa sulit karena dari perspektif satu service, setiap request terlihat legitimate.

Melalui Distributed Tracing #

Distributed tracing adalah alat terbaik untuk mendeteksi echo chamber — ia menunjukkan seluruh rantai request end-to-end.

// Tanda-tanda echo chamber di trace:

// Trace yang sehat:
// Client → A (50ms) → B (20ms) → [done]

// Trace yang menunjukkan echo chamber:
// Client → A → B → A → B → A → B → ... (ratusan span dari dua service)

// Di Jaeger/Zipkin: cari trace dengan:
// - Span count yang sangat tinggi dari dua service yang sama
// - Depth yang sangat dalam (nested calls yang tidak berakhir)
// - Duration yang jauh melebihi ekspektasi

// Dengan OpenTelemetry, trace menunjukkan ini secara visual

Melalui Log Analysis #

package main

import (
	"bufio"
	"fmt"
	"os"
	"regexp"
)

// detectEchoChamberFromLogs mendeteksi pola echo chamber dari log.
// Jika dua service saling memanggil lebih dari N kali dalam window waktu,
// kemungkinan terjadi echo chamber.
func detectEchoChamberFromLogs(logFile string, windowSeconds int) {
	callPairs := map[string]int{}
	timeWindows := map[string]string{}

	logLine := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) (\w+) -> (\w+) req=(\w+)`)

	f, err := os.Open(logFile)
	if err != nil {
		return
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := scanner.Text()
		// Parse log: timestamp, caller_service, callee_service, request_id
		match := logLine.FindStringSubmatch(line)
		if match == nil {
			continue
		}

		timestampStr, caller, callee := match[1], match[2], match[3]

		pair := caller + " ↔ " + callee
		reversePair := callee + " ↔ " + caller

		// Cek apakah pasangan yang berlawanan ada dalam window waktu
		if _, ok := timeWindows[reversePair]; ok {
			// Jika pasangan saling memanggil dalam window
			callPairs[pair]++
		}

		timeWindows[pair] = timestampStr
	}

	// Report pasangan yang mencurigakan
	for pair, count := range callPairs {
		if count > 10 { // threshold
			fmt.Printf("⚠️  Possible echo chamber: %s (%d mutual calls)\n", pair, count)
		}
	}
}

Melalui Metrics #

package main

import (
	"github.com/prometheus/client_golang/prometheus"
)

// Prometheus metrics untuk mendeteksi echo chamber

// Track setiap outbound request dengan sumber dan tujuan
var outboundRequests = prometheus.NewCounterVec(
	prometheus.CounterOpts{
		Name: "service_outbound_requests_total",
		Help: "Total outbound requests to other services",
	},
	[]string{"caller_service", "callee_service", "endpoint"},
)

// Jika service A dan B saling memanggil dengan frekuensi tinggi:
// Query Prometheus:
// rate(service_outbound_requests_total{caller_service="service-a",callee_service="service-b"}[1m])
// DAN
// rate(service_outbound_requests_total{caller_service="service-b",callee_service="service-a"}[1m])
// Keduanya tinggi secara bersamaan → sinyal echo chamber

// Alert rule:
// alert: PossibleEchoChamber
// expr: |
//   (
//     rate(service_outbound_requests_total{caller_service="service-a", callee_service="service-b"}[2m])
//     > 10
//   ) and (
//     rate(service_outbound_requests_total{caller_service="service-b", callee_service="service-a"}[2m])
//     > 10
//   )
// for: 1m
// annotations:
//   summary: "Possible echo chamber between service-a and service-b"

Pencegahan: Request Depth Limit #

Cara pertama dan paling sederhana untuk mencegah echo chamber adalah membatasi kedalaman request — berapa kali satu request bisa “memicu” request lain dalam satu chain.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"time"
)

const maxRequestDepth = 5 // maksimum 5 level request chaining

type ctxDepthKey struct{}

// checkRequestDepth mengecek dan menegakkan request depth limit.
// Depth di-propagate via header X-Request-Depth.
func checkRequestDepth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Ambil depth dari header (dikirim oleh caller)
		depth, err := strconv.Atoi(r.Header.Get("X-Request-Depth"))
		if err != nil {
			depth = 0
		}

		// Simpan depth di request context
		r = r.WithContext(context.WithValue(r.Context(), ctxDepthKey{}, depth))

		// Tolak jika sudah terlalu dalam
		if depth >= maxRequestDepth {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(508) // HTTP 508 Loop Detected
			fmt.Fprintf(w,
				`{"error":"Request depth limit exceeded","depth":%d,"max_depth":%d,"message":"Possible circular dependency detected"}`,
				depth, maxRequestDepth)
			return
		}

		next.ServeHTTP(w, r)
	})
}

// callService adalah wrapper untuk HTTP call ke service lain yang meng-increment depth.
// Gunakan ini di semua inter-service call, bukan httpx/requests langsung.
func callService(r *http.Request, url string) (map[string]interface{}, error) {
	depth := r.Context().Value(ctxDepthKey{}).(int)
	requestID := r.Header.Get("X-Request-ID")
	if requestID == "" {
		requestID = newRequestID()
	}

	client := &http.Client{Timeout: 10 * time.Second}

	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-Request-Depth", strconv.Itoa(depth+1)) // increment depth
	req.Header.Set("X-Request-ID", requestID)                // propagate ID untuk tracing
	req.Header.Set("X-Caller-Service", "service-a")          // identify caller

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result map[string]interface{}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, err
	}
	return result, nil
}

// Penggunaan: daftarkan middleware di router:
// mux.Handle("/orders", checkRequestDepth(http.HandlerFunc(createOrder)))

// createOrder memanggil service lain dengan automatic depth tracking
func createOrder(w http.ResponseWriter, r *http.Request) {
	var orderData map[string]interface{}
	json.NewDecoder(r.Body).Decode(&orderData)

	// Call ke service lain dengan automatic depth tracking
	user, err := callService(r, fmt.Sprintf("http://user-service/users/%v", orderData["user_id"]))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}

	json.NewEncoder(w).Encode(createOrderRecord(orderData, user))
}

Pencegahan: Idempotency Key untuk Event #

Ketika echo chamber terjadi melalui event atau webhook, idempotency key memastikan bahwa satu event tidak diproses lebih dari sekali.

package main

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"sort"
	"time"

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

var redisClient = redis.NewClient(&redis.Options{
	Addr: "redis:6379",
})

// isEventProcessed mengecek apakah event dengan ID ini sudah pernah diproses.
// Menggunakan Redis untuk menyimpan event ID yang sudah diproses.
func isEventProcessed(ctx context.Context, eventID string, ttlSeconds int) bool {
	key := fmt.Sprintf("processed_event:%s", eventID)

	// SET NX: set hanya jika belum ada (atomic).
	// Return true jika berhasil di-set (event belum pernah diproses).
	wasSet, err := redisClient.SetNX(ctx, key, "1", time.Duration(ttlSeconds)*time.Second).Result()
	if err != nil {
		return false
	}
	return !wasSet // true = sudah pernah diproses (tidak perlu proses lagi)
}

// processEventIdempotent memproses event dengan idempotency check.
// Jika event sudah pernah diproses, skip tanpa error.
func processEventIdempotent(ctx context.Context, event map[string]interface{}) map[string]interface{} {
	eventID, _ := event["event_id"].(string)
	if eventID == "" {
		// Generate event ID dari konten jika tidak ada
		pairs := make([]string, 0, len(event))
		for k, v := range event {
			pairs = append(pairs, fmt.Sprintf("%v:%v", k, v))
		}
		sort.Strings(pairs)

		h := sha256.New()
		fmt.Fprintf(h, "%v", pairs)
		eventID = hex.EncodeToString(h.Sum(nil))[:16]
	}

	if isEventProcessed(ctx, eventID, 3600) {
		logger.Info("Event already processed, skipping",
			"event_id", eventID, "event_type", event["type"])
		return map[string]interface{}{"status": "already_processed", "event_id": eventID}
	}

	// Proses event
	result := handleEvent(event)
	logger.Info("Event processed successfully",
		"event_id", eventID, "event_type", event["type"])
	return result
}

// Untuk webhook: selalu gunakan webhook ID dari provider
func paymentWebhook(w http.ResponseWriter, r *http.Request) {
	webhookID := r.Header.Get("X-Webhook-ID")
	if webhookID == "" {
		webhookID = r.FormValue("id")
	}

	if webhookID == "" {
		http.Error(w, `{"error": "Missing webhook ID"}`, http.StatusBadRequest)
		return
	}

	// Idempotency check
	if isEventProcessed(r.Context(), fmt.Sprintf("webhook:%s", webhookID), 3600) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `{"status": "already_processed"}`)
		return
	}

	// Proses webhook
	processPaymentEvent(r.Body)
	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, `{"status": "processed"}`)
}

Pencegahan: Event Source Tracking #

Untuk mencegah event storm, setiap event perlu menyimpan informasi tentang dari mana ia berasal — sehingga service bisa mendeteksi jika sedang dalam siklus.

package main

import (
	"fmt"
	"time"
)

// Event envelope yang menyimpan chain of causality
type EventEnvelope struct {
	EventID       string
	EventType     string
	Payload       map[string]interface{}
	CorrelationID string // sama untuk semua event terkait
	CausationID   string // ID dari event yang menyebabkan event ini
	CauseChain    []string
	SourceService string
	Timestamp     time.Time
}

const maxCauseChainDepth = 10

// NewEventEnvelope membuat event envelope baru dengan ID baru.
func NewEventEnvelope(eventType string, payload map[string]interface{}, source string) *EventEnvelope {
	return &EventEnvelope{
		EventID:       newEventID(),
		EventType:     eventType,
		Payload:       payload,
		CorrelationID: newCorrelationID(),
		CauseChain:    []string{},
		SourceService: source,
		Timestamp:     time.Now(),
	}
}

// Buat event baru yang merupakan akibat dari event ini.
func (e *EventEnvelope) CreateChildEvent(eventType string, payload map[string]interface{}, source string) (*EventEnvelope, error) {
	if len(e.CauseChain) >= maxCauseChainDepth {
		return nil, fmt.Errorf(
			"cause chain too deep (%d). possible echo chamber. chain: %v",
			len(e.CauseChain), e.CauseChain)
	}

	return &EventEnvelope{
		EventType:     eventType,
		Payload:       payload,
		CorrelationID: e.CorrelationID,                 // sama untuk semua event terkait
		CausationID:   e.EventID,                       // ID event penyebab
		CauseChain:    append(e.CauseChain, e.EventID), // tambah ke chain
		SourceService: source,
		Timestamp:     time.Now(),
	}, nil
}

// EchoChamberDetected menandakan cause chain yang terlalu dalam.
type EchoChamberDetected struct{ msg string }

func (e *EchoChamberDetected) Error() string { return e.msg }

// Consumer yang aware terhadap echo chamber
func consumeUserUpdatedEvent(envelope *EventEnvelope) {
	userID := envelope.Payload["user_id"]

	// Cek apakah event ini sudah dalam chain yang terlalu dalam
	if containsEventType(envelope.CauseChain, "user.profile.sync") {
		logger.Warning("Skipping user profile sync — already in cause chain",
			"cause_chain", envelope.CauseChain)
		return
	}

	// Update profil lokal
	updateLocalProfile(userID, envelope.Payload)

	// Kalau perlu trigger event lain, buat sebagai child event
	child, err := envelope.CreateChildEvent(
		"user.profile.sync",
		map[string]interface{}{"user_id": userID, "synced_at": time.Now().Unix()},
		"profile-service",
	)
	if err != nil {
		logger.Error("Echo chamber prevented: %v", err)
		// Stop chain — jangan publish event yang akan membuat loop
		return
	}
	publishEvent(child)
}

Pencegahan: Redesain Dependency yang Circular #

Solusi terbaik untuk echo chamber adalah menghilangkan circular dependency dari arsitektur. Jika Service A dan B saling bergantung, ada yang salah dengan pembagian tanggung jawab.

Pola redesain untuk menghilangkan circular dependency:

Problem: A ↔ B (circular) #

flowchart LR
    A["Service A"]
    B["Service B"]
    B -->|"validation"| A
    A -->|"data fetch"| B

Solusi 1: Extract shared dependency #

Buat Service C yang berisi data yang keduanya butuhkan.

  • A → C (baca data)
  • B → C (baca data)
  • A dan B tidak saling bergantung langsung.
flowchart TD
    A["Service A"]
    B["Service B"]
    C["C (shared data)"]
    A --> C
    B --> C

Solusi 2: Event-driven dengan unidirectional flow #

Ganti synchronous call dengan event:

  • A publish event → B consume (A tidak perlu respons dari B)
  • B publish event → A consume (B tidak perlu respons dari A)
  • Tidak ada request langsung, tidak ada loop.

Solusi 3: Aggregate data di upstream #

Biarkan caller (client/API gateway) mengumpulkan data dari kedua service:

  • A hanya bertanggung jawab untuk domain-nya
  • B hanya bertanggung jawab untuk domain-nya
  • Tidak ada inter-service call sama sekali
package main

// Contoh refactor: dari circular dependency ke event-driven

// SEBELUM (circular):
// Order Service
func createOrderCircular(orderData map[string]interface{}) {
	// Panggil User Service untuk validasi
	user := userService.GetUser(orderData["user_id"]) // → User Service
	// User Service juga panggil Order Service untuk cek limit order
	// → CIRCULAR!
	_ = user
}

// User Service
func getUserCircular(userID string) {
	user := db.GetUser(userID)
	// Cek order limit
	orders := orderService.GetOrders(userID) // → Order Service → CIRCULAR!
	user["can_order"] = len(orders) < user["order_limit"]
}

// SESUDAH (event-driven, tanpa circular):
// Order Service — hanya tahu tentang order
func createOrder(orderData map[string]interface{}) {
	// Tidak memanggil User Service!
	// Order limit info sudah ada di payload (dikirim oleh client/API gateway)
	if orderData["user_order_count"].(int) >= orderData["user_order_limit"].(int) {
		panic(OrderLimitExceeded{})
	}

	order := newOrder(orderData)
	db.Save(order)

	// Publish event — tidak perlu tahu siapa yang consume
	publishEvent("order.created", map[string]interface{}{"order_id": order.ID, "user_id": order.UserID})
}

// User Service — hanya tahu tentang user
func getUser(userID string) {
	// Tidak memanggil Order Service!
	// Hanya return data user yang ada di domain-nya sendiri
	_ = db.GetUser(userID)
}

// API Gateway atau BFF — yang mengagregasi
func checkout(request map[string]interface{}) {
	user := userService.GetUser(request["user_id"])
	orderCount := orderService.CountOrders(request["user_id"])

	// Combine data di layer atas, bukan di dalam service
	orderService.CreateOrder(map[string]interface{}{
		"user_order_count": orderCount,
		"user_order_limit": user["order_limit"],
	})
}

Circuit Breaker untuk Echo Chamber #

Jika circular dependency tidak bisa segera dihilangkan, circuit breaker bisa menjadi safety net untuk mencegah dampak terburuk dari echo chamber.

package main

import (
	"fmt"
	"time"
)

// Circuit breaker minimal untuk inter-service call
type CircuitBreaker struct {
	Name             string
	FailureThreshold int
	RecoveryTimeout  time.Duration
}

// CircuitOpenError dikembalikan ketika circuit terbuka.
type CircuitOpenError struct{ service string }

func (e *CircuitOpenError) Error() string {
	return fmt.Sprintf("circuit open for %s", e.service)
}

// Call menjalankan fn, menghitung kegagalan terhadap threshold.
func (cb *CircuitBreaker) Call(fn func() (interface{}, error)) (interface{}, error) {
	if cb.isOpen() {
		return nil, &CircuitOpenError{service: cb.Name}
	}
	result, err := fn()
	if err != nil {
		cb.recordFailure()
	}
	return result, err
}

// Circuit breaker di setiap inter-service call
var (
	userServiceBreaker  = &CircuitBreaker{Name: "user-service", FailureThreshold: 5, RecoveryTimeout: 30 * time.Second}
	orderServiceBreaker = &CircuitBreaker{Name: "order-service", FailureThreshold: 5, RecoveryTimeout: 30 * time.Second}
)

func getUserSafe(userID int) map[string]interface{} {
	result, err := userServiceBreaker.Call(func() (interface{}, error) {
		// pseudo HTTP call
		return httpGetJSON(fmt.Sprintf("http://user-service/users/%d", userID))
	})
	if err != nil {
		// User Service tidak tersedia atau sedang dalam echo chamber.
		// Kembalikan data minimal atau raise error yang jelas.
		if _, ok := err.(*CircuitOpenError); ok {
			return map[string]interface{}{"id": userID, "status": "unavailable"}
		}
		return nil
	}
	return result.(map[string]interface{})
}

// Dengan circuit breaker:
// Jika echo chamber terjadi dan call mulai gagal,
// circuit terbuka setelah 5 kegagalan
// → Request langsung gagal (fast fail) alih-alih loop terus
// → Dependency punya waktu untuk recover

Monitoring Echo Chamber #

package main

import (
	"context"
	"net/http"
	"strconv"

	"github.com/prometheus/client_golang/prometheus"
)

// Custom metrics untuk mendeteksi echo chamber lebih awal

// Track request depth distribution
var requestDepthHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
	Name:    "http_request_depth",
	Help:    "Distribution of request depth (how deep in the call chain)",
	Buckets: []float64{0, 1, 2, 3, 5, 10},
})

// Alert jika ada request dengan depth tinggi
var requestDepthExceeded = prometheus.NewCounter(prometheus.CounterOpts{
	Name: "request_depth_limit_exceeded_total",
	Help: "Number of requests rejected due to depth limits",
})

// Track mutual calls antar service
var mutualCalls = prometheus.NewCounterVec(prometheus.CounterOpts{
	Name: "inter_service_mutual_calls_total",
	Help: "Number of times service A called B while processing a call from B",
}, []string{"service_a", "service_b"})

// trackRequestDepth mencatat depth dan caller dari setiap request.
func trackRequestDepth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		depth, _ := strconv.Atoi(r.Header.Get("X-Request-Depth"))
		requestDepthHistogram.Observe(float64(depth))

		// Simpan depth di request context
		r = r.WithContext(context.WithValue(r.Context(), "request_depth", depth))

		caller := r.Header.Get("X-Caller-Service")
		if caller != "" && caller != "service-a" {
			// Track bahwa service lain memanggil kita
			mutualCalls.WithLabelValues(caller, "service-a").Inc()
		}

		next.ServeHTTP(w, r)
	})
}
# Alert rules untuk echo chamber

groups:
  - name: echo_chamber
    rules:
      # Alert jika ada request yang di-reject karena depth limit
      - alert: RequestDepthLimitExceeded
        expr: rate(request_depth_limit_exceeded_total[5m]) > 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Request depth limit exceeded — possible echo chamber"
          description: "{{ $value }} requests/s rejected due to depth limit"
          runbook_url: "https://runbook.company.com/echo-chamber"

      # Alert untuk mutual call rate yang tinggi
      - alert: HighMutualCallRate
        expr: |
          rate(inter_service_mutual_calls_total[2m]) > 10          
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "High mutual call rate between {{ $labels.service_a }} and {{ $labels.service_b }}"

Anti-Pattern yang Harus Dihindari #

package main

// ✗ Anti-pattern 1: Tidak ada request depth limit
// Service yang saling memanggil tanpa batas
// Satu request bisa menghasilkan ribuan call sebelum timeout
// ✓ Solusi: X-Request-Depth header + batas maksimum

// ✗ Anti-pattern 2: Webhook tanpa idempotency check
func paymentWebhook(w http.ResponseWriter, r *http.Request) {
	processPayment(r.Body) // bisa diproses berkali-kali!
	w.Write([]byte(`{"ok": true}`))
}
// ✓ Solusi: simpan webhook ID yang sudah diproses di Redis

// ✗ Anti-pattern 3: Event consumer yang publish event dengan type yang sama
func handleUserUpdated(event Event) {
	updateProfile(event.UserID)
	publish("user.updated", event) // akan di-consume oleh dirinya sendiri lagi!
}
// ✓ Solusi: event consumer tidak publish event yang sama dengan yang di-consume
// atau: cek source service sebelum publish

// ✗ Anti-pattern 4: Saling bergantung tanpa abstraksi
// Service A import langsung client Service B
// Service B import langsung client Service A
// ✓ Solusi: redesain dengan shared dependency atau event-driven

// ✗ Anti-pattern 5: Tidak ada timeout pada inter-service call
func antiPattern5() {
	response := httpGet("http://service-b/data") // bisa hang selamanya
	// ✓ Solusi: selalu set timeout
	response = httpGetWithTimeout("http://service-b/data", 10*time.Second)
}

Checklist Echo Chamber Prevention #

DESAIN ARSITEKTUR:
  □ Tidak ada circular dependency antar service dalam desain
  □ Setiap service hanya bergantung pada service yang ada di "level lebih rendah"
  □ Dependency graph sudah direview dan tidak ada siklus
  □ Event consumer tidak publish event yang bisa memicu dirinya sendiri

REQUEST DEPTH LIMIT:
  □ Semua inter-service HTTP call meneruskan header X-Request-Depth
  □ Setiap service reject request jika depth melebihi batas (misalnya 5-10)
  □ Depth limit rejection di-log dan di-alert sebagai anomali kritis

IDEMPOTENCY:
  □ Semua webhook endpoint memiliki idempotency check
  □ Semua event consumer memiliki deduplication logic
  □ Event ID / webhook ID disimpan untuk mencegah duplicate processing
  □ TTL untuk idempotency key dikonfigurasi dengan benar

EVENT SOURCING:
  □ Setiap event menyimpan causation ID (ID event penyebab)
  □ Cause chain depth dibatasi
  □ Event consumer cek apakah event type yang sama sudah ada di chain

MONITORING:
  □ Alert dipasang untuk request depth limit exceeded
  □ Mutual call rate antar service dimonitor
  □ Distributed tracing aktif untuk semua inter-service call
  □ Trace dengan span count sangat tinggi diidentifikasi sebagai anomali

CIRCUIT BREAKER:
  □ Circuit breaker ada di semua inter-service call
  □ Jika echo chamber terjadi dan request gagal, circuit terbuka otomatis
  □ Fast fail mencegah resource exhaustion dari request yang looping

Ringkasan #

  • Echo chamber API terjadi ketika dua atau lebih service saling memanggil dalam siklus yang tidak berakhir — satu request user bisa menghasilkan ribuan request internal dalam detik, menguras connection pool dan memory secara eksponensial.
  • Circular dependency adalah akar masalah — jika Service A bergantung pada B dan B bergantung pada A, echo chamber hanya menunggu waktu yang tepat untuk terjadi. Redesain boundary service untuk menghilangkan siklus ini.
  • Request depth limit adalah perlindungan pertama — propagate X-Request-Depth header di setiap inter-service call. Tolak request yang sudah terlalu dalam dengan HTTP 508 Loop Detected.
  • Idempotency key mencegah webhook dan event storm — simpan ID webhook/event yang sudah diproses di Redis. Jika ID yang sama datang lagi, skip tanpa error.
  • Event consumer tidak boleh publish event yang sama — jika consumer event user.updated mempublish kembali user.updated, ia akan di-consume oleh dirinya sendiri dalam loop tanpa henti.
  • Cause chain dalam event envelope memungkinkan deteksi awal — setiap event menyimpan chain dari event-event yang menyebabkannya. Jika chain terlalu panjang atau mengandung event type yang sama, stop chain tersebut.
  • Distributed tracing adalah alat deteksi terbaik — trace dengan ratusan span dari dua service yang sama adalah sinyal jelas echo chamber. Setup alerting untuk trace yang anomalous.
  • Circuit breaker sebagai safety net — jika echo chamber sudah terjadi dan request mulai gagal, circuit breaker membuka sirkuit dan mencegah resource exhaustion lebih lanjut.
  • Timeout wajib di semua inter-service call — tanpa timeout, request yang terjebak dalam loop bisa berlangsung sampai proses crash. Set timeout yang realistic untuk setiap dependency.
  • Monitoring mutual call rate memberikan early warning — jika rate Service A memanggil B dan B memanggil A keduanya naik bersamaan, ini adalah sinyal echo chamber sebelum sistem crash.

← Sebelumnya: Observability
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact