502 atau 504 yang muncul sporadis sering dikira masalah load balancer, gateway, atau jaringan. Dalam studi kasus ini, penyebab sebenarnya adalah HTTP client tanpa timeout: request ke layanan eksternal menggantung terlalu lama, thread/worker pool aplikasi terkunci, lalu API tidak lagi punya worker untuk memproses request baru.
Masalah ini berbahaya karena tidak selalu terlihat pada error rate di awal. Aplikasi bisa tampak sehat, CPU rendah, memory stabil, tetapi latensi naik, queue request menumpuk, dan gateway akhirnya mengembalikan 502/504 karena upstream tidak merespons tepat waktu.
Konteks Kasus: API Kadang 502/504 Saat Memanggil Layanan Eksternal
Misalkan sebuah backend menyediakan endpoint checkout. Di dalam prosesnya, backend memanggil layanan eksternal untuk validasi risiko, pembayaran, atau pengecekan ketersediaan. Arsitektur sederhananya:
- Client memanggil
POST /checkout. - API gateway atau reverse proxy meneruskan request ke backend.
- Backend memanggil layanan eksternal via HTTP.
- Backend mengembalikan respons ke client.
Ketika layanan eksternal sesekali lambat atau tidak merespons, backend tetap menunggu tanpa batas yang jelas. Jika request masuk terus berdatangan, worker yang sedang menunggu panggilan HTTP tidak bisa digunakan untuk request lain. Pada titik tertentu, worker pool penuh dan gateway melihat backend sebagai upstream yang lambat atau tidak tersedia.
Gejala di Log dan Metrik
Gejala awal biasanya tidak langsung menunjuk ke timeout HTTP client. Beberapa tanda yang umum terlihat:
- Log gateway: muncul 502, 503, atau 504 dari upstream, sering kali dengan pesan seperti upstream timeout, connection reset, atau bad gateway.
- Log aplikasi: tidak selalu ada exception. Banyak request hanya berhenti lama di tengah proses dan baru gagal setelah timeout dari gateway.
- Latensi endpoint naik: p95/p99 meningkat tajam, sedangkan p50 mungkin masih normal.
- Jumlah request in-flight naik: request aktif bertambah dan sulit turun saat layanan eksternal lambat.
- Thread/worker pool penuh: active worker mendekati maksimum, queue request bertambah, throughput turun.
- CPU tidak selalu tinggi: ini penting. Thread sedang menunggu I/O, bukan melakukan komputasi berat.
Contoh pola log aplikasi yang mencurigakan:
INFO checkout request started request_id=req-101
INFO calling external risk service request_id=req-101
... tidak ada log selesai selama puluhan detik atau menit ...
WARN gateway returned 504 request_id=req-101 upstream_response_time=60s
Jika instrumentasi belum lengkap, log seperti ini sering membuat tim mengira masalah terjadi di gateway. Padahal gateway hanya menjadi komponen pertama yang menyerah karena backend tidak mengembalikan respons tepat waktu.
Hipotesis Awal yang Keliru
Dalam debugging insiden seperti ini, beberapa hipotesis awal sering muncul tetapi tidak menjelaskan seluruh gejala:
- Load balancer bermasalah: masuk akal karena error terlihat di gateway, tetapi tidak menjelaskan mengapa worker backend penuh.
- Database lambat: perlu dicek, tetapi query latency normal dan connection pool database tidak penuh.
- CPU bottleneck: metrik CPU rendah sampai sedang, sehingga bukan bottleneck komputasi.
- Memory leak: memory stabil, GC tidak menunjukkan tekanan ekstrem, tetapi request tetap menumpuk.
- Traffic spike semata: traffic memang memperparah, tetapi akar masalahnya adalah worker tertahan oleh I/O eksternal tanpa batas waktu.
Kesalahan umum adalah hanya menaikkan jumlah worker atau timeout gateway. Ini mungkin menunda kegagalan, tetapi tidak mengatasi penyebabnya. Jika layanan eksternal menggantung, worker tambahan juga akan ikut habis.
Menemukan Root Cause: HTTP Client Tanpa Timeout
Root cause ditemukan dengan menghubungkan tiga sinyal: request in-flight naik, thread/worker pool penuh, dan banyak stack trace atau trace span berhenti di panggilan HTTP eksternal.
Pada aplikasi berbasis thread pool, setiap request biasanya diproses oleh satu worker. Jika worker melakukan panggilan HTTP sinkron dan panggilan itu tidak memiliki timeout, worker dapat terblokir sangat lama. Dampaknya:
- Request baru harus menunggu worker tersedia.
- Queue internal bertambah.
- Gateway kehabisan waktu menunggu respons backend.
- Backend terlihat tidak sehat walaupun prosesnya belum mati.
Contoh pseudocode sebelum perbaikan:
function handleCheckout(request) {
validateInput(request)
// Masalah: tidak ada connect timeout, read timeout, atau total deadline.
response = httpClient.post('https://risk-service.example/validate', {
customerId: request.customerId,
amount: request.amount
})
if response.status != 200 {
return errorResponse(502, 'risk validation failed')
}
return createOrder(request)
}
Kode tersebut terlihat sederhana, tetapi berbahaya. Jika koneksi sulit dibuat, server eksternal menerima koneksi tetapi tidak pernah mengirim respons, atau jaringan menggantung di tengah pembacaan response body, worker bisa tertahan lebih lama daripada batas toleransi sistem.
Cara Mengonfirmasi dengan Thread Dump atau Profiling
Jika runtime mendukung thread dump, ambil beberapa snapshot saat insiden. Cari banyak worker yang berhenti di operasi socket, HTTP client, atau pembacaan response body. Untuk runtime non-threaded atau event-loop, cari metrik yang setara: jumlah pending promise/future, active request, atau event loop delay.
Trace terdistribusi juga sangat membantu. Span endpoint backend biasanya panjang, dan child span panggilan ke layanan eksternal menjadi titik terpanjang atau tidak selesai sampai request dibatalkan.
Reproduksi Lokal dengan Mock Service Lambat
Untuk membuktikan masalah, buat mock service yang menerima request tetapi sengaja menunda respons. Contoh sederhana menggunakan Python standar:
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
class SlowHandler(BaseHTTPRequestHandler):
def do_POST(self):
time.sleep(120) # simulasi layanan eksternal menggantung/lambat
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"ok": true}')
server = HTTPServer(('0.0.0.0', 9001), SlowHandler)
server.serve_forever()
Arahkan konfigurasi backend agar memanggil mock service tersebut, lalu kirim request paralel ke endpoint yang bermasalah:
# Contoh sederhana. Sesuaikan URL dan payload dengan aplikasi Anda.
for i in $(seq 1 200); do
curl -s -X POST http://localhost:8080/checkout \
-H 'Content-Type: application/json' \
-d '{"customerId":"c-1","amount":100000}' &
done
wait
Jika backend menggunakan worker pool terbatas dan HTTP client tidak punya timeout, Anda akan melihat request menggantung, worker aktif naik sampai maksimum, dan request baru mulai gagal atau menunggu sangat lama. Reproduksi seperti ini berguna karena memisahkan masalah dari faktor eksternal yang sulit dikendalikan di production.
Perbaikan Utama: Timeout yang Eksplisit
Setiap panggilan ke layanan eksternal sebaiknya memiliki batas waktu. Minimal, bedakan beberapa jenis timeout:
- Connect timeout: batas waktu untuk membuat koneksi TCP/TLS.
- Read atau response timeout: batas waktu menunggu data respons setelah request dikirim.
- Total deadline: batas waktu keseluruhan operasi, termasuk retry jika ada.
Nilai timeout harus ditentukan berdasarkan SLO, karakteristik dependency, dan budget latensi endpoint. Jangan menyalin angka sembarangan. Jika gateway timeout 30 detik, tidak masuk akal membiarkan panggilan eksternal menunggu 60 detik di dalam backend, apalagi jika endpoint harus melakukan beberapa operasi lain.
Contoh pseudocode setelah perbaikan:
httpClient = HttpClient({
connectTimeoutMs: 500,
responseTimeoutMs: 1500,
maxConnections: 100,
maxIdleConnections: 20
})
function handleCheckout(request) {
deadline = Deadline.afterMs(2500)
try {
response = httpClient.post(
'https://risk-service.example/validate',
body = {
customerId: request.customerId,
amount: request.amount
},
timeoutMs = deadline.remainingMs()
)
} catch TimeoutError {
recordMetric('risk_service.timeout')
return errorResponse(504, 'risk service timeout')
} catch NetworkError {
recordMetric('risk_service.network_error')
return errorResponse(502, 'risk service unavailable')
}
if response.status >= 500 {
return errorResponse(502, 'risk service failed')
}
return createOrder(request)
}
Prinsip pentingnya adalah fail fast secara terkendali. Daripada worker menunggu tanpa batas, aplikasi mengembalikan error yang jelas, melepas worker, dan memberi kesempatan sistem tetap melayani request lain.
Connection Pool: Batasi dan Pisahkan Resource
Timeout saja tidak cukup jika connection pool tidak dikendalikan. HTTP client biasanya menggunakan connection pool agar koneksi bisa digunakan ulang. Namun pool yang tidak dibatasi atau dipakai bersama tanpa perencanaan bisa menyebabkan dependency lambat menghabiskan resource untuk dependency lain.
Praktik yang disarankan:
- Tetapkan maksimum koneksi per host atau per dependency.
- Gunakan pool terpisah untuk dependency kritis jika memungkinkan.
- Pasang timeout saat menunggu koneksi dari pool, bukan hanya saat membaca respons.
- Monitor jumlah active connection, idle connection, dan pending acquire.
Trade-off-nya: pool terlalu kecil dapat membatasi throughput, tetapi pool terlalu besar dapat memperparah overload ke layanan eksternal dan menghabiskan file descriptor atau memory. Mulailah dari kapasitas realistis berdasarkan beban, lalu validasi dengan load test.
Retry Terbatas dengan Backoff, Bukan Retry Membabi Buta
Retry dapat membantu untuk error sementara seperti koneksi reset atau timeout singkat. Tetapi retry juga bisa memperburuk insiden jika dilakukan tanpa batas, tanpa backoff, atau untuk operasi yang tidak idempotent.
Gunakan aturan berikut:
- Batasi jumlah retry, misalnya hanya satu atau dua kali sesuai budget latensi.
- Gunakan exponential backoff dengan jitter agar request tidak mencoba ulang bersamaan.
- Jangan retry jika total deadline endpoint hampir habis.
- Retry hanya untuk error yang aman, misalnya timeout koneksi atau status 5xx tertentu.
- Pastikan operasi aman diulang, atau gunakan idempotency key untuk operasi yang mengubah state.
Contoh pseudocode retry yang memperhatikan deadline:
function callRiskService(payload, deadline) {
maxAttempts = 2
for attempt in range(1, maxAttempts + 1) {
try {
return httpClient.post(
'https://risk-service.example/validate',
body = payload,
timeoutMs = min(1000, deadline.remainingMs())
)
} catch TimeoutError or NetworkError as err {
if attempt == maxAttempts or deadline.remainingMs() <= 0 {
throw err
}
sleepMs = jitter(backoffMs(attempt))
if sleepMs >= deadline.remainingMs() {
throw err
}
sleep(sleepMs)
}
}
}
Retry yang baik selalu tunduk pada deadline keseluruhan. Jika tidak, satu request client bisa berubah menjadi beberapa request upstream yang justru mempercepat habisnya worker dan connection pool.
Circuit Breaker untuk Mencegah Kegagalan Menular
Circuit breaker berguna ketika dependency eksternal sedang bermasalah. Jika rasio error atau timeout melewati ambang tertentu, circuit dibuka sementara. Saat circuit terbuka, aplikasi tidak terus memanggil dependency yang sudah diketahui gagal; aplikasi langsung mengembalikan fallback atau error yang terkendali.
Secara umum, circuit breaker memiliki tiga kondisi:
- Closed: request diteruskan normal, metrik keberhasilan dan kegagalan dihitung.
- Open: request ditolak cepat tanpa memanggil dependency.
- Half-open: sebagian kecil request percobaan diizinkan untuk mengecek apakah dependency sudah pulih.
Pseudocode sederhananya:
function validateRisk(payload) {
if circuitBreaker.isOpen('risk-service') {
recordMetric('risk_service.circuit_open')
return riskUnavailableFallback()
}
try {
response = callRiskService(payload, currentDeadline())
circuitBreaker.recordSuccess('risk-service')
return response
} catch TimeoutError or NetworkError as err {
circuitBreaker.recordFailure('risk-service')
throw err
}
}
Circuit breaker bukan pengganti timeout. Tanpa timeout, circuit breaker juga terlambat mendapatkan sinyal kegagalan karena request masih menggantung. Urutannya: pasang timeout terlebih dahulu, lalu retry terbatas, lalu circuit breaker.
Metrik dan Alert yang Perlu Dipasang
Insiden ini sulit didiagnosis jika hanya mengandalkan log error. Pasang metrik yang menunjukkan kesehatan request, worker, dan dependency eksternal.
Metrik aplikasi
- Request rate, error rate, dan latency p50/p95/p99 per endpoint.
- Jumlah request in-flight per endpoint atau per service.
- Ukuran queue jika server memiliki request queue atau executor queue.
- Active worker/thread dan utilisasi worker pool.
- Timeout count, network error count, dan status code dari dependency eksternal.
Metrik HTTP client
- Durasi panggilan per dependency, termasuk p95/p99.
- Jumlah koneksi aktif dan idle.
- Jumlah request yang menunggu koneksi dari pool.
- Jumlah timeout connect, timeout read/response, dan pembatalan request.
- Jumlah retry dan retry yang akhirnya gagal.
Alert yang praktis
- p95/p99 latency endpoint melewati SLO selama beberapa menit.
- Error rate 502/504 meningkat di gateway dan backend.
- Worker pool aktif mendekati kapasitas maksimum.
- Request in-flight naik tidak wajar sementara throughput turun.
- Timeout ke dependency eksternal meningkat.
- Circuit breaker terbuka terlalu lama atau terlalu sering.
Alert sebaiknya menggabungkan beberapa sinyal agar tidak terlalu bising. Misalnya, worker pool hampir penuh ditambah p99 latency naik lebih berguna daripada alert hanya berdasarkan satu spike singkat.
Checklist Debugging Saat 502 Sporadis Terjadi
- Cek apakah 502/504 berasal dari gateway karena upstream timeout atau dari aplikasi langsung.
- Bandingkan latency gateway, latency aplikasi, dan latency dependency eksternal.
- Lihat jumlah request in-flight dan utilisasi worker pool saat error meningkat.
- Ambil thread dump, goroutine dump, trace, atau profiling snapshot sesuai runtime.
- Cari operasi yang menggantung di HTTP client, socket read, DNS, TLS handshake, atau connection pool acquire.
- Verifikasi konfigurasi timeout: connect, response/read, write jika relevan, pool acquire, dan total deadline.
- Reproduksi dengan mock service lambat untuk memastikan worker habis ketika dependency menggantung.
- Pasang perbaikan bertahap: timeout, pool limit, retry terbatas, circuit breaker, lalu alert.
Kesalahan Umum yang Perlu Dihindari
- Hanya menaikkan timeout gateway: ini memperpanjang waktu client menunggu dan bisa memperbesar penumpukan request.
- Menaikkan jumlah worker tanpa batas: dapat menunda gejala, tetapi resource tetap akan habis jika dependency menggantung.
- Retry tanpa deadline: satu request bisa menjadi banyak request upstream dan memperparah overload.
- Menggunakan satu HTTP client global tanpa batas pool yang jelas: satu dependency bermasalah dapat mengganggu dependency lain.
- Tidak membaca atau menutup response body sesuai aturan library: pada banyak HTTP client, ini dapat mencegah koneksi dikembalikan ke pool.
- Tidak memisahkan timeout per operasi: connect timeout dan read timeout menyelesaikan masalah yang berbeda.
Kesimpulan
Debug 502 sporadis karena thread pool habis oleh HTTP client tanpa timeout membutuhkan observasi dari beberapa lapisan: gateway, aplikasi, worker pool, dan dependency eksternal. Gejalanya sering menipu karena CPU bisa tetap rendah dan log aplikasi tidak selalu menunjukkan exception yang jelas.
Perbaikan yang efektif adalah memberi batas waktu eksplisit pada setiap panggilan eksternal, mengatur connection pool, menerapkan retry terbatas dengan backoff dan deadline, serta menambahkan circuit breaker untuk mencegah kegagalan menular. Setelah itu, pasang metrik dan alert yang memantau latency, in-flight request, worker pool, timeout, retry, dan status circuit breaker. Dengan pendekatan ini, backend gagal secara terkendali saat dependency bermasalah, bukan ikut menggantung sampai gateway mengembalikan 502/504.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!