Expert · Standard library essentials · topic 32 of 46 · 35 min
Calling other APIs safely with the HTTP client
Explanation
Why a careful client matters
Backends constantly call other services: a payment gateway, an SMS provider, a weather API. If one of them hangs and your client waits forever, your own server slowly runs out of goroutines and connections, and then it hangs too. Most production outages involving Go HTTP clients come from a few simple mistakes covered here.
The safe pattern
var client = &http.Client{Timeout: 10 * time.Second} // create once, reuse
func getRates(ctx context.Context) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/rates", nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("call rates api: %w", err) // network error, timeout...
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rates api: unexpected status %s", resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // read at most 1 MB
}The five rules, explained
- Set a Timeout. http.Get, http.Post and http.DefaultClient have NO timeout. Timeout covers the whole exchange: connecting, sending, and reading the body.
- Reuse one client. An http.Client keeps a pool of open connections (keep-alive) in its Transport. Creating a new client per request throws that away and can exhaust ports under load. A client is safe to share between goroutines.
- Close the body. If you forget resp.Body.Close(), the connection cannot go back to the pool and you leak resources. Reading the body to the end also helps the connection to be reused.
- Check the status code. err is only non-nil for problems like DNS failure, refused connection or timeout. A 404 or 500 response comes back with err == nil.
- Limit what you read. A broken or hostile server could send gigabytes; io.LimitReader caps it.
Sending JSON
type Payout struct {
Account string `json:"account"`
Paise int64 `json:"amount_paise"`
}
body, err := json.Marshal(Payout{Account: "XXXX1234", Paise: 250000})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.example.com/payouts", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", orderID) // if the provider supports it
resp, err := client.Do(req)Decoding a JSON response
var out struct {
Status string `json:"status"`
TxnID string `json:"txn_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return fmt.Errorf("decode payout response: %w", err)
}Retries, carefully
Networks fail now and then, so clients often retry. Retry only when it is safe: GET requests, or POSTs that the provider makes idempotent (the same Idempotency-Key never charges twice). Wait longer after each try (exponential backoff, for example 200 ms, 400 ms, 800 ms) and stop after a few attempts or when the context is done. Never blindly retry a payment.
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Calling other APIs safely with the HTTP client · 1/7
Calling other APIs is everyday backend work. A few rules keep your service from hanging.
Prefer a video by a teacher? Find free videos on “golang http client timeout close response body best practices” on YouTube ↗
In real life: Fetching an exchange rate for a remittance app
A remittance app shows the INR rate before a user sends money abroad. The rate comes from a partner API that is usually fast but sometimes slow. With a 3-second client timeout, a status check and a size limit, a slow or broken partner leads to a friendly "rates unavailable, try again" message instead of a frozen app.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
var client = &http.Client{Timeout: 3 * time.Second}
type Rate struct {
Pair string `json:"pair"`
Rate float64 `json:"rate"`
}
func fetchRate(ctx context.Context, url string) (Rate, error) {
var r Rate
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return r, err
}
resp, err := client.Do(req)
if err != nil {
return r, fmt.Errorf("rates unavailable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return r, fmt.Errorf("rates unavailable: status %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(&r)
return r, err
}
func main() {
// a fake partner API for this demo
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"pair":"USD/INR","rate":83.25}`)
}))
defer srv.Close()
r, err := fetchRate(context.Background(), srv.URL)
fmt.Println(r, err) // {USD/INR 83.25} <nil>
}Practice (0/4 done)
What timeout does http.Get use by default?
A server answers 500 Internal Server Error. What does client.Do return?
Which line is the bug? 1: resp, err := client.Get(url) 2: defer resp.Body.Close() 3: if err != nil { 4: return err 5: }
Write func getJSON(ctx context.Context, url string, out any) error that uses a shared client with a 5-second timeout, checks for status 200, limits the body to 1 MB and decodes JSON into out. Test it against an httptest.NewServer that returns {"city":"Pune","temp":31}.
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- JSON with encoding/jsonStandard library essentials · same module
- Files, time and structured logsStandard library essentials · same module
- Loading, errors and slow networksFull-Stack Development · Connecting frontend and backend
- fetch: talking to serversJavaScript Programming (language in depth) · Asynchronous JavaScript
- HTTP requests, responses and methodsBackend Development · How the web and backends work
- Calling the API with fetchFull-Stack Development · Connecting frontend and backend
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
Why reuse one http.Client instead of creating one per request?
Answer: It keeps a pool of open connections that can be reused
The client's Transport pools keep-alive connections.
Is an http.Client safe to use from many goroutines?
Answer: Yes
Clients and Transports are safe for concurrent use.
What does io.LimitReader(resp.Body, 1<<20) do?
Answer: Stops reading after 1 MB
1<<20 bytes is 1 MiB; reading stops there.
When is it safe to retry a request automatically?
Answer: For idempotent requests, such as GET or a POST with an idempotency key
Retrying a non-idempotent request can repeat its effect, like charging twice.
Which request is safe to retry automatically after a network error?Related
Answer: GET /api/notes
Reading notes again changes nothing. A POST may already have worked, so repeating it can create duplicates or double payments.
What must the body of a JSON POST be?Related
Answer: A string made with JSON.stringify
fetch sends text; stringify the object.
Which part of an HTTP request carries the JSON data you send?Related
Answer: The body
The body comes after the headers and the empty line, and holds the data.
Which context does an HTTP handler normally start from?Related
Answer: r.Context()
r.Context() is cancelled when the client goes away or the server shuts down.
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- 2 more topics in “Standard library essentials” before its exam: JSON with encoding/json, Files, time and structured logs.
- Module 7 exam: Standard library essentials · 14 questions · 25 min, without help. 4 topics of this module still to finish first.
- Then the next module, Building a REST API with a database. 46 topics left in the course before the final exam.