Gejala: HTTP 200 OK dengan Downstream Failure
Pola implementasi asynchronous task langsung di dalam HTTP handler sering digunakan untuk operasi non-blocking seperti audit logging, push notification, atau invalidasi cache. Masalah klasik yang sering muncul di Go Fiber adalah kegagalan intermiten pada worker downstream dengan pesan kesalahan:
context canceledKlien HTTP menerima status 200 OK tanpa anomali, namun data audit log tidak pernah masuk ke database, atau driver SQL membatalkan query di tengah jalan (misalnya error pq: canceling statement due to user request di PostgreSQL).
Observabilitas: Indikasi pada Log dan Tracing
Identifikasi masalah ini dapat diverifikasi melalui structured logging dan distributed tracing (OpenTelemetry). Pola berikut umumnya terlihat:
- Log downstream: Logger di dalam goroutine mencatat error
context.Canceledtepat beberapa milidetik setelah handler selesai menulis response. - Distributed Tracing: Span downstream ditandai dengan status error, sementara parent HTTP span berstatus success. Error terjadi persis pada saat parent span ditutup.
- Karakteristik Latensi: Query downstream yang sangat cepat (< 2ms) sering kali berhasil karena selesai sebelum HTTP connection ditutup, sedangkan query yang membutuhkan I/O disk atau network (> 10ms) konsisten mengalami pembatalan. Kondisi balapan (race condition) ini menyebabkan isu tampak bersifat intermiten.
Root Cause: Lifecycle Request pada Engine Fasthttp
Go Fiber dibangun di atas fasthttp, bukan package standar net/http. Fasthttp mengoptimalkan alokasi memori melalui pooling objek (sync.Pool) dengan pendekatan zero-allocation.
Lifecycle c.UserContext()
Ketika request HTTP masuk, Fiber menginisialisasi struct fiber.Ctx dan context dasar yang dapat diakses melalui c.UserContext(). Context ini memiliki siklus hidup yang terikat ketat dengan lifecycle koneksi HTTP fasthttp:
- Koneksi TCP diterima oleh worker fasthttp.
- Context request dibuat dan diteruskan ke pipeline middleware serta handler.
- Handler selesai dieksekusi dan mengembalikan response ke klien.
- Fasthttp menutup/mereset request context dan memanggil fungsi
cancel()internal sebelum mengembalikan structRequestCtxkesync.Pool.
Saat Anda memanggil go func() dan menyuntikkan c.UserContext() ke dalamnya, goroutine tersebut bergantung pada context yang akan segera dibatalkan oleh runtime Fiber begitu handler utama keluar (return). Jika klien memutus koneksi lebih awal atau Fiber menyelesaikan siklus request, seluruh I/O yang menggunakan context tersebut otomatis dihentikan.
Bahaya Tambahan: Memory Reuse Fasthttp
Selain pembatalan context, pooling fasthttp mendaur ulang buffer byte request body. Meneruskan pointer, slice, atau string yang mereferensikan memory buffer Fiber (seperti hasil langsung dari c.Body()) ke dalam goroutine independen memicu data race dan korupsi memori karena buffer tersebut akan ditimpa oleh request HTTP berikutnya.
Perbandingan Implementasi: Buggy vs Perbaikan
1. Implementasi Buggy (Raw c.UserContext)
Kode berikut mendistribusikan context yang terikat ke request lifecycle dan rentan pembatalan dini:
app.Post("/api/v1/orders", func(c *fiber.Ctx) error {
var req OrderRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
orderID, err := orderRepo.Create(c.UserContext(), req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// BUG: Menggunakan c.UserContext() langsung di goroutine background
go func() {
// Gagal jika handler return lebih cepat daripada eksekusi audit log
if err := auditService.Record(c.UserContext(), "ORDER_CREATED", orderID); err != nil {
log.Printf("Gagal audit log: %v", err) // Error: context canceled
}
}()
return c.Status(fiber.StatusOK).JSON(fiber.Map{"order_id": orderID})
})2. Implementasi Aman: context.WithoutCancel (Go 1.21+)
Go 1.21 memperkenalkan context.WithoutCancel(). Fungsi ini membuat context baru yang mempertahankan seluruh context values (seperti Trace ID, Span Context, Request Metadata) tetapi memutus sinyal pembatalan dari parent context.
app.Post("/api/v1/orders", func(c *fiber.Ctx) error {
var req OrderRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
orderID, err := orderRepo.Create(c.UserContext(), req)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// 1. Lepaskan cancellation signal, pertahankan trace/values
detachedCtx := context.WithoutCancel(c.UserContext())
// 2. Berikan batas timeout eksplisit agar goroutine tidak leak
bgCtx, cancel := context.WithTimeout(detachedCtx, 5*time.Second)
// 3. Salin data yang dibutuhkan (pass by value) untuk menghindari memory corruption fasthttp
eventData := AuditEvent{
Action: "ORDER_CREATED",
OrderID: orderID,
}
go func(ctx context.Context, cancelFunc context.CancelFunc, data AuditEvent) {
defer cancelFunc()
if err := auditService.Record(ctx, data.Action, data.OrderID); err != nil {
log.Printf("Gagal audit log: %v", err)
}
}(bgCtx, cancel, eventData)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"order_id": orderID})
})3. Solusi untuk Go Versi < 1.21
Untuk Go versi sebelum 1.21, buat context struct kustom yang membungkus parent context dan menolak pembatalan:
type detachedContext struct {
context.Context
}
func (detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (detachedContext) Done() <-chan struct{} { return nil }
func (detachedContext) Err() error { return nil }
func Detach(ctx context.Context) context.Context {
return detachedContext{Context: ctx}
}Aturan Penggunaan Goroutine di Web Framework
Prinsip: Jangan pernah meneruskan request context mentah ke goroutine yang masa hidupnya melebihi siklus eksekusi handler.
- Isolasi Memori: Lakukan deep copy atau assignment by-value terhadap payload sebelum dispatch goroutine. Jangan melewatkan pointer ke struct request milik Fiber.
- Selalu Gunakan Timeout: Context yang dilepas dari parent via
context.WithoutCancel()tidak akan pernah selesai jika downstream mengalami deadlock. Selalu bungkus dengancontext.WithTimeout(). - Batas Reliabilitas In-Memory: Goroutine lokal tidak persisten. Jika container menerima sinyal SIGTERM atau crash, data pada goroutine background akan hilang. Untuk operasi kritikal (transaksional, billing, audit kepatuhan), gunakan distributed task queue seperti Redis Stream, RabbitMQ, atau database outbox pattern.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!