Belajar Golang dari Nol #22: HTTP Client ke API Pihak Ketiga
Di Belajar Golang dari Nol #21 kita belajar mengirim email dari aplikasi Go, lengkap dengan retry saat SMTP bermasalah. Sekarang arah pandangnya kita balik. Sejak bagian 11 aplikasi kita selalu jadi server, pihak yang dipanggil. Kali ini kita jadi pihak yang memanggil. Aplikasi kita akan bertamu ke API milik orang lain. Kalau kamu baru bergabung di seri ini, mampir dulu ke daftar lengkap seri supaya urutannya jelas.
Kenapa aplikasimu hampir pasti butuh HTTP client
Hampir tidak ada aplikasi bisnis yang hidup sendirian. Toko online perlu cek ongkir ke API kurir sebelum checkout. Sistem kasir perlu memotong pembayaran lewat payment gateway seperti Midtrans atau Xendit. Aplikasi PPDB perlu kirim notifikasi lewat WhatsApp gateway. Dashboard keuangan perlu ambil kurs dollar hari ini.
Semua kebutuhan itu jalannya sama: aplikasimu mengirim HTTP request ke server orang lain, lalu membaca balasannya. Alatnya sudah ada di paket net/http yang kita pakai sejak membangun REST API. Bedanya, sekarang kita memakai sisi client-nya.
Jangan pakai http.Get polos di production
Cara tercepat memanggil API memang satu baris:
resp, err := http.Get("https://api.contoh.com/data")
Kode ini jalan, dan justru itu bahayanya. http.Get memakai http.DefaultClient, dan DefaultClient tidak punya timeout sama sekali. Kalau server tujuan lambat atau koneksinya menggantung, request-mu ikut menggantung. Bukan sebentar. Selamanya.
Di bagian 17 kita sudah bahas kenapa timeout itu nyawa. Setiap request yang menggantung menahan satu goroutine, satu koneksi, dan kadang satu user yang menunggu di depan layar. Kalau API pihak ketiga sedang down dan kamu tidak pasang timeout, antrean request menumpuk sampai aplikasimu ikut tumbang. Padahal yang rusak bukan aplikasimu.
Solusinya sederhana: buat http.Client sendiri dengan timeout eksplisit, lalu pakai ulang di seluruh aplikasi.
var httpClient = &http.Client{
Timeout: 10 * time.Second,
}
Satu client ini aman dipakai dari banyak goroutine sekaligus. Jangan membuat client baru tiap request, karena client menyimpan pool koneksi yang bisa dipakai ulang.
GET yang benar: context, Body.Close, cek status
Begini pola lengkap request GET yang layak masuk production:
func ambilTodo(ctx context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://jsonplaceholder.typicode.com/todos/1", nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request gagal: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status tidak terduga: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
Ada tiga kebiasaan penting di sini.
Pertama, http.NewRequestWithContext. Request ini membawa context, jadi kalau user membatalkan halaman atau deadline lewat, request ke pihak ketiga ikut dibatalkan. Ini sambungan langsung dari materi context di bagian 17.
Kedua, defer resp.Body.Close(). Ini wajib, bukan hiasan. Selama body belum ditutup, koneksi TCP di baliknya tidak bisa dikembalikan ke pool. Lupa menutup body di satu fungsi yang dipanggil ribuan kali berarti ribuan koneksi bocor. Gejalanya pelan: aplikasi makin lama makin berat, file descriptor habis, lalu error aneh muncul di jam sibuk. Tulis defer resp.Body.Close() tepat setelah cek error, sebelum kode lain.
Ketiga, cek resp.StatusCode sebelum parse. Server yang membalas error 500 sering mengirim halaman HTML, bukan JSON. Kalau kamu langsung decode tanpa cek status, error yang muncul adalah “invalid character” yang membingungkan, padahal masalah aslinya server sedang rusak.
Decode JSON respons ke struct
Untuk latihan, pakai API publik yang stabil seperti jsonplaceholder.typicode.com atau httpbin.org. Keduanya gratis dan memang disediakan untuk uji coba. Endpoint /todos/1 di jsonplaceholder membalas JSON seperti ini:
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Kita tampung ke struct dengan json tag, persis seperti saat kita menerima request body di bagian 11, hanya arahnya terbalik:
type Todo struct {
UserID int `json:"userId"`
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
func main() {
body, err := ambilTodo(context.Background())
if err != nil {
log.Fatal(err)
}
var todo Todo
if err := json.Unmarshal(body, &todo); err != nil {
log.Fatal(err)
}
fmt.Printf("judul: %s, selesai: %t\n", todo.Title, todo.Completed)
}
Field yang tidak ada di struct akan diabaikan, jadi kamu cukup mendeklarasikan field yang benar-benar kamu pakai. Respons API pihak ketiga sering gemuk, ambil yang perlu saja.
POST JSON: kirim data plus kunci API
Memanggil payment gateway atau WhatsApp gateway hampir selalu berupa POST dengan body JSON dan header autentikasi. Polanya begini:
type PermintaanBayar struct {
OrderID string `json:"order_id"`
Jumlah int64 `json:"jumlah"`
Metode string `json:"metode"`
}
func buatTagihan(ctx context.Context) error {
payload, err := json.Marshal(PermintaanBayar{
OrderID: "INV-2026-001",
Jumlah: 150000,
Metode: "qris",
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.pembayaran.example.com/v1/charge",
bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("PAYMENT_API_KEY"))
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("request gagal: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pembayaran ditolak, status %d: %s", resp.StatusCode, body)
}
return nil
}
Urutannya selalu sama: marshal struct jadi byte, bungkus dengan bytes.NewReader, set header Content-Type supaya server tahu isi body-nya JSON, lalu set Authorization dengan kunci API yang diambil dari environment variable. Bukan dari string yang ditulis langsung di kode.
Tiga jenis kegagalan, tiga perlakuan berbeda
Saat memanggil API eksternal, error itu bukan satu jenis. Ada tiga, dan perlakuannya beda.
Error jaringan. httpClient.Do mengembalikan error: DNS gagal, koneksi ditolak, atau timeout. Kamu bahkan tidak dapat respons. Kegagalan seperti ini sering sementara, jadi retry masuk akal.
Status 4xx. Server menerima request-mu dan menolaknya. 400 berarti body-mu salah format, 401 kunci API salah, 404 endpoint keliru, 422 datanya tidak valid. Ini bug di sisimu. Mengulang request yang sama akan ditolak lagi dengan alasan yang sama. Jangan retry, catat errornya dan perbaiki penyebabnya.
Status 5xx. Server pihak ketiga sedang bermasalah. Bukan salahmu, dan biasanya sembuh sendiri. Retry pantas di sini, dengan jeda yang membesar seperti pola backoff yang kita pakai untuk email di bagian 21.
func doDenganRetry(ctx context.Context,
buatReq func() (*http.Request, error)) (*http.Response, error) {
var errTerakhir error
for percobaan := 1; percobaan <= 3; percobaan++ {
req, err := buatReq()
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
errTerakhir = err // error jaringan, coba lagi
} else if resp.StatusCode >= 500 {
resp.Body.Close()
errTerakhir = fmt.Errorf("server membalas %d", resp.StatusCode)
} else {
return resp, nil // sukses atau 4xx, keduanya final
}
jeda := time.Duration(percobaan*percobaan) * time.Second
select {
case <-time.After(jeda):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("menyerah setelah 3 percobaan: %w", errTerakhir)
}
Perhatikan dua detail. Request dibuat ulang lewat buatReq di tiap percobaan, karena body request hanya bisa dibaca sekali. Dan jeda backoff dipotong oleh ctx.Done(), jadi kalau user sudah pergi, kita tidak sibuk mengulang untuk siapa-siapa.
Bungkus jadi client struct yang rapi
Kalau kode pemanggilan API berserakan di handler, tiap perubahan endpoint memaksa kamu mengedit banyak tempat. Ikuti struktur proyek yang kita susun di bagian 16: bungkus semua urusan satu layanan eksternal ke dalam satu struct.
type OngkirClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
func NewOngkirClient(baseURL, apiKey string) *OngkirClient {
return &OngkirClient{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
type Ongkir struct {
Kurir string `json:"kurir"`
Biaya int64 `json:"biaya"`
Estimasi string `json:"estimasi"`
}
func (c *OngkirClient) CekOngkir(ctx context.Context,
asal, tujuan string, beratGram int) ([]Ongkir, error) {
url := fmt.Sprintf("%s/ongkir?asal=%s&tujuan=%s&berat=%d",
c.baseURL, asal, tujuan, beratGram)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("gagal menghubungi API ongkir: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API ongkir membalas status %d", resp.StatusCode)
}
var daftar []Ongkir
if err := json.NewDecoder(resp.Body).Decode(&daftar); err != nil {
return nil, fmt.Errorf("respons ongkir tidak valid: %w", err)
}
return daftar, nil
}
Perhatikan tipe kembaliannya: []Ongkir, struct domain milikmu sendiri. Bukan []byte, bukan map[string]interface{}. Pemakai client ini tidak perlu tahu bentuk JSON pihak ketiga. Kalau suatu hari kamu ganti penyedia ongkir, cukup ubah isi client ini, seluruh handler tetap aman.
Bonus besar dari pola ini: gampang dites. Karena baseURL bisa disuntik dari luar, kamu bisa mengarahkannya ke server palsu buatan httptest.NewServer, alat yang sudah kita kenal di bagian 14.
func TestCekOngkir(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `[{"kurir":"JNE","biaya":9000,"estimasi":"2-3 hari"}]`)
}))
defer server.Close()
client := NewOngkirClient(server.URL, "kunci-tes")
hasil, err := client.CekOngkir(context.Background(), "purwokerto", "semarang", 1000)
if err != nil {
t.Fatalf("tidak mengharapkan error: %v", err)
}
if hasil[0].Kurir != "JNE" {
t.Errorf("kurir salah: %s", hasil[0].Kurir)
}
}
Tes ini jalan tanpa internet, tanpa kunci API asli, dan selesai dalam hitungan milidetik.
Rahasia di env, dan sopan santun ke pihak ketiga
Dua aturan terakhir sebelum latihan. Pertama, kunci API adalah rahasia. Simpan di environment variable seperti yang kita biasakan di bagian 18, dan jangan pernah menuliskannya ke log. Baris seperti log.Printf("memanggil API dengan key %s", apiKey) kelihatan sepele saat debugging, tapi log tersimpan lama dan dibaca banyak orang. Log-lah nama layanan dan status, bukan kredensialnya.
Kedua, hormati rate limit. Hampir semua API komersial membatasi jumlah request per menit, dan membalas 429 kalau kamu kebablasan. Baca dokumentasi limitnya, beri jeda antar request massal, dan kalau kebutuhanmu besar, atur antrean dengan worker pool seperti di bagian 19. Akun yang terus menabrak limit bisa diblokir, dan itu masalah yang jauh lebih mahal daripada menambah satu time.Sleep.
Latihan: client kurs dipakai dari handler produk
Sekarang kita gabungkan semuanya. Program di bawah ini punya tiga bagian: server palsu dari httptest yang berperan sebagai API kurs, sebuah KursClient, dan handler /produk yang memakai client itu untuk menghitung harga rupiah.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"time"
)
type KursClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
func NewKursClient(baseURL, apiKey string) *KursClient {
return &KursClient{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
type Kurs struct {
Kode string `json:"kode"`
Nilai float64 `json:"nilai"`
}
func (c *KursClient) Ambil(ctx context.Context, kode string) (Kurs, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/kurs?kode="+kode, nil)
if err != nil {
return Kurs{}, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return Kurs{}, fmt.Errorf("gagal menghubungi API kurs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Kurs{}, fmt.Errorf("API kurs membalas status %d", resp.StatusCode)
}
var k Kurs
if err := json.NewDecoder(resp.Body).Decode(&k); err != nil {
return Kurs{}, fmt.Errorf("respons kurs tidak valid: %w", err)
}
return k, nil
}
func main() {
// API palsu, berperan sebagai penyedia kurs sungguhan
palsu := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("kode") != "USD" {
http.Error(w, `{"error":"kode tidak dikenal"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"kode":"USD","nilai":16250}`)
}))
defer palsu.Close()
client := NewKursClient(palsu.URL, "kunci-rahasia")
http.HandleFunc("/produk", func(w http.ResponseWriter, r *http.Request) {
kurs, err := client.Ambil(r.Context(), "USD")
if err != nil {
log.Printf("cek kurs gagal: %v", err)
http.Error(w, `{"error":"layanan kurs sedang bermasalah"}`,
http.StatusBadGateway)
return
}
hargaUSD := 25.0
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"produk":"Lisensi Pro","harga_rupiah":%.0f}`,
hargaUSD*kurs.Nilai)
})
log.Println("server jalan di :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Jalankan dengan go run main.go, lalu coba dari terminal lain. Ini output saat semuanya lancar:
$ curl localhost:8080/produk
{"produk":"Lisensi Pro","harga_rupiah":406250}
Sekarang uji jalur gagalnya. Ubah kode mata uang di handler dari "USD" menjadi "EUR", jalankan ulang, lalu panggil lagi:
$ curl localhost:8080/produk
{"error":"layanan kurs sedang bermasalah"}
// di log server:
2026/07/27 10:15:03 cek kurs gagal: API kurs membalas status 404
Perhatikan pembagian perannya. User hanya menerima pesan yang sopan dan status 502, sedangkan detail teknisnya masuk ke log untukmu. API pihak ketiga boleh rusak, aplikasimu tetap berdiri dan menjawab dengan jelas. Itu inti dari seluruh bagian ini.
Sebagai latihan tambahan, ganti httptest.Server di atas dengan API publik sungguhan, atau tambahkan doDenganRetry ke dalam method Ambil supaya error 5xx dicoba ulang otomatis.
Selanjutnya: bicara serius dengan database
Aplikasi kita sekarang bisa melayani request, mengirim email, dan memanggil layanan lain. Di bagian 23 kita kembali ke dalam: Transaksi Database dan Migrasi Skema. Kamu akan belajar menjaga data tetap konsisten saat beberapa operasi harus berhasil bersama-sama, dan mengelola perubahan struktur tabel tanpa drama.
Kalau sistemmu perlu terhubung ke payment gateway, WhatsApp gateway, atau API internal antar divisi, tim Arrazy sudah sering mengerjakan integrasi seperti ini. Lihat layanan pengembangan sistem aplikasi kami untuk berdiskusi soal kebutuhanmu.
Artikel Lainnya di Kategori Golang
Golang 14 Agustus 2026
Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go
Belajar dua pola background di Go: worker pool dengan channel untuk tugas berat dan job terjadwal dengan time.Ticker, lengkap dengan shutdown rapi.
Baca Artikel
Golang 7 Agustus 2026
Belajar Golang dari Nol #12: Koneksi Database dengan database/sql
API produk dari bagian 11 akhirnya menyimpan data beneran: koneksi SQLite, placeholder anti SQL injection, Query, Scan, sampai refactor handler.
Baca Artikel
Golang 15 Agustus 2026
Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go
Cara upload file di API Go: multipart/form-data, batas ukuran body, validasi tipe dari isi file, nama acak anti path traversal, sampai simpan ke database.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel