Timeout context Go mencegah panggilan DB lambat dan permintaan eksternal menumpuk. Pelajari propagasi batas waktu, pembatalan, dan default yang aman.

Satu permintaan yang lambat jarang "hanya lambat." Saat menunggu, permintaan itu menjaga goroutine tetap hidup, memakan memori untuk buffer dan objek respons, dan sering menempati koneksi database atau slot di pool. Ketika cukup banyak permintaan lambat menumpuk, API Anda berhenti melakukan pekerjaan berguna karena sumber daya terbatasnya terikat menunggu.
Anda biasanya merasakannya di tiga tempat. Goroutine menumpuk dan overhead penjadwalan naik, sehingga latensi memburuk untuk semua orang. Pool database kehabisan koneksi bebas, sehingga query cepat pun mulai antre di belakang yang lambat. Memori naik dari data dalam perjalanan dan respons yang baru dibangun sebagian, yang meningkatkan kerja GC.
Menambah lebih banyak server sering kali tidak memperbaiki masalah. Jika setiap instance terkena bottleneck yang sama (pool DB kecil, upstream lambat, batas rate bersama), Anda hanya memindahkan antrean dan membayar lebih sementara error tetap melompat.
Bayangkan sebuah handler yang melakukan fan-out: memuat user dari PostgreSQL, memanggil layanan pembayaran, lalu memanggil layanan rekomendasi. Jika panggilan rekomendasi macet dan tidak ada yang membatalkannya, permintaan tidak pernah selesai. Koneksi DB mungkin dikembalikan, tetapi goroutine dan sumber daya client HTTP tetap terikat. Kalikan itu dengan ratusan permintaan dan Anda mendapatkan meltdown lambat.
Tujuannya sederhana: tetapkan batas waktu yang jelas, hentikan pekerjaan saat waktunya habis, bebaskan sumber daya, dan kembalikan error yang dapat diprediksi. Timeout context di Go memberi setiap langkah sebuah deadline sehingga pekerjaan berhenti saat pengguna tidak lagi menunggu.
Sebuah context.Context adalah objek kecil yang Anda teruskan ke bawah rantai panggilan sehingga setiap lapisan sepakat pada satu hal: kapan permintaan ini harus dihentikan. Timeout adalah cara umum untuk mencegah satu dependensi yang lambat mengikat server Anda.
Sebuah context dapat membawa tiga jenis informasi: sebuah deadline (kapan pekerjaan harus berhenti), sinyal pembatalan (seseorang memutuskan berhenti lebih awal), dan beberapa nilai yang scoped ke request (gunakan ini hemat-hati, dan jangan untuk data besar).
Pembatalan bukan sihir. Sebuah context mengekspos channel Done(). Ketika channel itu ditutup, permintaan dibatalkan atau waktunya habis. Kode yang menghormati context memeriksa Done() (sering dengan select) dan mengembalikan lebih awal. Anda juga bisa memeriksa ctx.Err() untuk mengetahui mengapa context berakhir, biasanya context.Canceled atau context.DeadlineExceeded.
Gunakan context.WithTimeout untuk "berhenti setelah X detik." Gunakan context.WithDeadline ketika Anda sudah tahu waktu cutoff yang tepat. Gunakan context.WithCancel ketika kondisi induk harus menghentikan pekerjaan (klien terputus, pengguna pindah halaman, Anda sudah punya jawaban).
Saat sebuah context dibatalkan, perilaku yang benar itu membosankan tapi penting: hentikan pekerjaan, berhenti menunggu I/O lambat, dan kembalikan error yang jelas. Jika sebuah handler menunggu query database dan context berakhir, kembalikan cepat dan biarkan panggilan database berhenti jika mendukung context.
Tempat paling aman untuk menghentikan permintaan lambat adalah boundary tempat trafik masuk ke layanan Anda. Jika sebuah permintaan akan timeout, Anda ingin itu terjadi secara prediktabel dan sejak awal, bukan setelah mengikat goroutine, koneksi DB, dan memori.
Mulai di edge (load balancer, API gateway, reverse proxy) dan tetapkan batas keras berapa lama sebuah request diizinkan hidup. Itu melindungi service Go Anda bahkan jika seorang handler lupa menetapkan timeout.
Di dalam server Go Anda, atur timeout HTTP sehingga server tidak menunggu selamanya untuk klien yang lambat atau respons yang macet. Sebagai minimum, konfigurasikan timeout untuk membaca header, membaca body penuh request, menulis respons, dan menjaga koneksi idle tetap hidup.
Pilih anggaran request default yang sesuai produk Anda. Untuk banyak API, 1 sampai 3 detik adalah titik awal yang masuk akal untuk permintaan tipikal, dengan batas lebih tinggi untuk operasi yang diketahui lambat seperti ekspor. Angka pastinya kurang penting dibanding konsistensi, pengukuran, dan aturan yang jelas untuk pengecualian.
Respons streaming butuh perhatian ekstra. Mudah membuat stream tak berujung secara tidak sengaja di mana server mempertahankan koneksi terbuka dan menulis potongan kecil terus-menerus, atau menunggu selamanya sebelum byte pertama. Putuskan di awal apakah endpoint benar-benar stream. Jika tidak, terapkan waktu total maksimum dan waktu maksimum-ke-byte-pertama.
Setelah boundary punya deadline yang jelas, jauh lebih mudah untuk mempropagasikan deadline itu ke seluruh permintaan.
Tempat paling sederhana untuk memulai adalah handler HTTP. Di sinilah satu request masuk ke sistem Anda, jadi ini lokasi alami untuk menetapkan batas keras.
Buat context baru dengan deadline, dan pastikan Anda memanggil cancel. Lalu teruskan context itu ke segala hal yang mungkin block: pekerjaan database, panggilan HTTP, atau komputasi lambat.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
Aturan yang baik: jika sebuah fungsi bisa menunggu I/O, ia harus menerima context.Context. Jaga handler agar tetap terbaca dengan mendorong detail ke helper kecil seperti loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Jika deadline tercapai (atau klien terputus), hentikan pekerjaan dan kembalikan respons yang ramah pengguna. Pemetaan umum adalah context.DeadlineExceeded ke 504 Gateway Timeout, dan context.Canceled ke "client is gone" (sering tanpa body respons).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Pola ini mencegah penumpukan. Setelah timer habis, setiap fungsi yang sadar context di down-chain mendapatkan sinyal berhenti yang sama dan bisa keluar cepat.
Setelah handler Anda punya context dengan deadline, aturan terpenting sederhana: gunakan ctx yang sama hingga panggilan database. Begitulah cara timeout menghentikan pekerjaan alih-alih hanya menghentikan handler dari menunggu.
Dengan database/sql, pilih metode yang sadar context:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Jika anggaran handler adalah 2 detik, database harus mendapat hanya sebagian dari itu. Sisakan waktu untuk encoding JSON, dependensi lain, dan penanganan error. Titik awal sederhana adalah memberi Postgres 30% sampai 60% dari total anggaran. Dengan deadline handler 2 detik, itu mungkin 800ms sampai 1.2s.
Ketika context dibatalkan, driver meminta Postgres untuk menghentikan query. Biasanya koneksi kembali ke pool dan bisa dipakai ulang. Jika pembatalan terjadi saat momen jaringan buruk, driver mungkin membuang koneksi itu dan membuka koneksi baru nanti. Bagaimanapun, Anda menghindari goroutine yang menunggu selamanya.
Saat memeriksa error, perlakukan timeout berbeda dari kegagalan DB nyata. Jika errors.Is(err, context.DeadlineExceeded), Anda kehabisan waktu dan harus mengembalikan timeout. Jika errors.Is(err, context.Canceled), klien pergi dan Anda harus berhenti dengan tenang. Error lain adalah masalah query normal (SQL salah, baris hilang, izin).
Jika handler Anda punya deadline, panggilan HTTP keluar harus menghormatinya juga. Kalau tidak, klien menyerah, tapi server Anda terus menunggu upstream yang lambat dan mengikat goroutine, socket, dan memori.
Buat request outbound dengan context induk sehingga pembatalan mengalir otomatis:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Timeout per-panggilan itu adalah jaring pengaman. Deadline induk tetap bos sebenarnya. Satu jam untuk seluruh permintaan, ditambah topi lebih kecil untuk langkah berisiko.
Juga atur timeout di level transport. Context membatalkan request, tetapi timeout transport melindungi Anda dari handshake lambat dan server yang tidak pernah mengirim header.
Satu detail yang menyengat tim: body respons harus ditutup di setiap jalur. Jika Anda keluar lebih awal (cek status code, error decode JSON, timeout context), tetap tutup body. Bocornya body bisa diam-diam menguras koneksi di pool dan berubah menjadi lonjakan latensi "acak".
Skenario konkret: API Anda memanggil penyedia pembayaran. Klien timeout setelah 2 detik, tetapi upstream macet selama 30 detik. Tanpa pembatalan request dan timeout transport, Anda terus membayar untuk tunggu 30 detik itu untuk setiap permintaan yang ditinggalkan.
Satu permintaan biasanya menyentuh lebih dari satu hal yang lambat: kerja handler, query database, dan satu atau lebih API eksternal. Jika Anda memberi setiap langkah timeout yang longgar, total waktu diam-diam tumbuh hingga pengguna merasakan dan server menumpuk.
Penganggaran adalah perbaikan paling sederhana. Tetapkan satu deadline induk untuk seluruh permintaan, lalu berikan setiap dependensi irisan waktu yang lebih kecil. Deadline anak harus lebih awal daripada induk agar Anda gagal cepat dan masih punya waktu mengembalikan error bersih.
Aturan praktis yang berlaku di layanan nyata:
Hindari menumpuk timeout yang saling berantem. Jika context handler punya deadline 2 detik dan client HTTP Anda punya timeout 10 detik, Anda aman tetapi membingungkan. Jika sebaliknya, client mungkin memotong lebih awal karena alasan yang tidak terkait.
Untuk pekerjaan background (audit log, metric, email), jangan gunakan context request. Gunakan context terpisah dengan timeout singkat sehingga pembatalan klien tidak membunuh cleanup penting.
Sebagian besar bug timeout bukan di handler. Mereka terjadi satu atau dua lapisan ke bawah, di mana deadline diam-diam hilang. Jika Anda menetapkan timeout di edge tapi mengabaikannya di tengah, Anda masih bisa berakhir dengan goroutine, query DB, atau panggilan HTTP yang terus berjalan setelah klien pergi.
Polanya yang sering muncul sederhana:
context.Background() (atau TODO). Itu memutus pekerjaan dari pembatalan klien dan deadline handler.ctx.Done(). Request dibatalkan, tetapi kode Anda terus menunggu.context.WithTimeout sendiri. Anda berakhir dengan banyak timer dan deadline yang membingungkan.ctx ke panggilan blocking (DB, HTTP outbound, publish message). Timeout handler tidak ada artinya jika panggilan dependensi mengabaikannya.Kegagalan klasik: Anda menambahkan timeout 2 detik di handler, lalu repository Anda menggunakan context.Background() untuk query database. Saat beban, query lambat terus berjalan bahkan setelah klien menyerah, dan tumpukan tumbuh.
Perbaiki dasar: teruskan ctx sebagai argumen pertama melalui stack panggilan Anda. Di dalam pekerjaan panjang, tambahkan pemeriksaan cepat seperti select { case <-ctx.Done(): return ctx.Err() default: }. Pemetakan context.DeadlineExceeded ke respons timeout (sering 504) dan context.Canceled ke respons gaya pembatalan klien (sering 408 atau 499 tergantung konvensi Anda).
Timeout hanya membantu kalau Anda bisa melihatnya terjadi dan memastikan sistem pulih dengan bersih. Ketika sesuatu lambat, permintaan harus berhenti, sumber daya harus dilepas, dan API harus tetap responsif.
Untuk setiap request, log set kecil field yang sama sehingga Anda bisa membandingkan request normal vs timeout. Sertakan deadline context (jika ada) dan apa yang mengakhiri pekerjaan.
Field berguna meliputi deadline (atau "none"), total elapsed time, alasan pembatalan (timeout vs client canceled), label operasi singkat ("db.query users", "http.call billing"), dan request ID.
Polanya minimal terlihat seperti ini:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Log membantu debug satu permintaan. Metrik menunjukkan tren.
Lacak beberapa sinyal yang biasanya melonjak lebih awal saat timeout salah: jumlah timeout per route dan dependensi, request in-flight (seharusnya stabil di bawah beban), waktu menunggu pool DB, dan persentil latensi (p95/p99) yang dipisah berdasarkan sukses vs timeout.
Buat kelambatan jadi bisa diprediksi. Tambahkan delay debug-only ke satu handler, perlambat query DB dengan penantian sengaja, atau bungkus panggilan eksternal dengan server test yang tidur. Lalu verifikasi dua hal: Anda melihat error timeout, dan pekerjaan berhenti tak lama setelah pembatalan.
Tes beban kecil juga membantu. Jalankan 20–50 request bersamaan selama 30–60 detik dengan satu dependensi yang dipaksa lambat. Jumlah goroutine dan request in-flight harus naik lalu stabil. Jika terus naik, sesuatu mengabaikan pembatalan context.
Timeout hanya membantu jika diterapkan di mana pun sebuah request bisa menunggu. Sebelum deploy, lakukan satu kali peninjauan kode dan pastikan aturan yang sama diikuti di setiap handler.
context.DeadlineExceeded dan context.Canceled.http.NewRequestWithContext (atau req = req.WithContext(ctx)) dan client punya timeout transport (dial, TLS, response header). Hindari bergantung pada http.DefaultClient di jalur produksi.Latihan singkat "dependensi lambat" sebelum rilis sangat berharga. Tambahkan delay buatan 2 detik ke satu query SQL dan konfirmasi tiga hal: handler mengembalikan tepat waktu, panggilan DB benar-benar berhenti (bukan hanya handler), dan log Anda jelas menyatakan itu adalah timeout DB.
Bayangkan endpoint seperti GET /v1/account/summary. Satu aksi user memicu tiga hal: query PostgreSQL (account plus aktivitas terbaru) dan dua panggilan HTTP eksternal (misal, pemeriksaan status billing dan lookup enrichment profil).
Berikan seluruh request anggaran keras 2 detik. Tanpa anggaran, satu dependensi lambat bisa mengikat goroutine, koneksi DB, dan memori sampai API Anda mulai timeout di mana-mana.
Pemecahan sederhana mungkin 800ms untuk query DB, 600ms untuk panggilan eksternal A, dan 600ms untuk panggilan eksternal B.
Setelah Anda tahu deadline keseluruhan, teruskan itu ke bawah. Setiap dependensi mendapat timeout lebih kecil sendiri, tapi tetap mewarisi pembatalan dari induk.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Jika panggilan eksternal B melambat dan butuh 2.5 detik, handler Anda harus berhenti menunggu pada 600ms, membatalkan pekerjaan in-flight, dan mengembalikan respons timeout yang jelas ke klien. Klien melihat kegagalan cepat alih-alih spinner yang menggantung.
Log Anda harus membuatnya jelas apa yang memakai anggaran, misalnya: DB selesai cepat, eksternal A sukses, eksternal B mencapai batasnya dan mengembalikan context deadline exceeded.
Setelah satu endpoint nyata bekerja baik dengan timeout dan pembatalan, ubah itu menjadi pola yang bisa diulang. Terapkan end-to-end: deadline handler, panggilan DB, dan HTTP outbound. Lalu salin struktur yang sama ke endpoint berikutnya.
Anda akan bergerak lebih cepat jika memusatkan bagian membosankan: helper timeout boundary, wrapper yang memastikan ctx diteruskan ke panggilan DB dan HTTP, serta satu pemetaan error dan format log yang konsisten.
Jika Anda ingin mencoba pola ini dengan cepat, Koder.ai (koder.ai) dapat menghasilkan handler Go dan panggilan service dari prompt chat, dan Anda bisa mengekspor source code untuk menerapkan helper timeout dan anggaran sendiri. Tujuannya adalah konsistensi: panggilan lambat berhenti lebih awal, error terlihat sama, dan debugging tidak tergantung siapa yang menulis endpoint.
Permintaan yang lambat menahan sumber daya terbatas saat menunggu: sebuah goroutine, memori untuk buffer dan objek respons, dan seringkali koneksi database atau koneksi client HTTP. Ketika cukup banyak permintaan menunggu bersamaan, antrean terbentuk, latensi meningkat untuk seluruh lalu lintas, dan layanan bisa gagal meskipun setiap permintaan pada akhirnya akan selesai.
Tetapkan batas waktu yang jelas di batas permintaan (proxy/gateway dan di server Go), turunkan context ber-waktu di handler, dan teruskan ctx itu ke setiap panggilan yang melakukan blocking (database dan HTTP keluar). Saat batas waktu tercapai, kembalikan segera dengan respons timeout yang konsisten dan hentikan pekerjaan in-flight yang mendukung pembatalan.
Gunakan context.WithTimeout(parent, d) ketika Anda ingin “berhenti setelah durasi ini,” yang paling umum di handler. Gunakan context.WithDeadline(parent, t) jika Anda sudah punya waktu cutoff tertentu yang harus dipatuhi. Gunakan context.WithCancel(parent) ketika kondisi internal harus menghentikan pekerjaan lebih awal, seperti “kita sudah punya jawaban” atau “klien terputus.”
Selalu panggil fungsi cancel, biasanya dengan defer cancel() segera setelah membuat context turunan. Membatalkan melepaskan timer dan memberi sinyal berhenti yang jelas ke pekerjaan anak, terutama pada jalur kode yang keluar lebih awal sebelum deadline terpenuhi.
Buat context request sekali di handler dan teruskan sebagai argumen pertama ke fungsi yang mungkin blocking. Cara cepat mengecek: cari context.Background() atau context.TODO() di jalur kode request; itu sering memutus propagasi pembatalan dan deadline.
Gunakan metode database yang sadar context seperti QueryContext, QueryRowContext, dan ExecContext (atau ekuivalennya di driver Anda). Ketika context berakhir, driver dapat meminta PostgreSQL untuk membatalkan query sehingga Anda tidak terus membakar waktu dan koneksi setelah permintaan selesai.
Lampirkan context request induk ke request outbound menggunakan http.NewRequestWithContext(ctx, ...), dan juga konfigurasikan timeout client/transport sehingga Anda terlindungi selama koneksi, TLS, dan menunggu header respons. Bahkan saat error atau respons non-200, selalu tutup body respons agar koneksi kembali ke pool.
Pilih satu anggaran total untuk permintaan terlebih dulu, lalu berikan setiap dependensi potongan waktu yang lebih kecil yang muat di dalamnya, menyisakan buffer kecil untuk overhead handler dan encoding respons. Jika context induk hanya punya sedikit waktu tersisa, jangan mulai pekerjaan mahal yang tidak bisa selesai sebelum deadline.
Standar umum adalah memetakan context.DeadlineExceeded ke 504 Gateway Timeout dengan pesan singkat seperti “request timed out.” Untuk context.Canceled, biasanya berarti klien terputus; sering tindakan terbaik adalah menghentikan pekerjaan dan tidak menulis body, sehingga Anda tidak membuang sumber daya lebih lanjut.
Kesalahan paling umum adalah: mengganti context request dengan context.Background(), memulai retry atau sleep tanpa memeriksa ctx.Done(), dan lupa melampirkan ctx ke panggilan blocking. Isu lain yang halus adalah menumpuk banyak timeout yang tidak terkait di berbagai tempat, yang membuat kegagalan sulit ditafsirkan dan dapat menyebabkan pemutusan lebih awal yang mengejutkan.