Skip to content

Expert · Standard library essentials · topic 32 of 46 · 35 min

Calling other APIs safely with the HTTP client

Explanation

Use one shared http.Client with a Timeout; the default client used by http.Get never times out. Always close resp.Body, check resp.StatusCode yourself (a 404 or 500 is not an error in Go), and limit how much of the body you read. Add a context to cancel calls when the caller gives up.

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

go
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
}
Timeout on the client, context on the request, defer Close, check the status, limit the body.

The five rules, explained

  1. Set a Timeout. http.Get, http.Post and http.DefaultClient have NO timeout. Timeout covers the whole exchange: connecting, sending, and reading the body.
  2. 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.
  3. 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.
  4. 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.
  5. Limit what you read. A broken or hostile server could send gigabytes; io.LimitReader caps it.

Sending JSON

go
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)
Build the request yourself when you need headers, a body or a context.

Decoding a JSON response

go
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)
}
Decode straight from the body stream; no need to read it all into memory first.

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.

Careful: Common mistake: defer resp.Body.Close() BEFORE checking err. When err is not nil, resp is nil, and the deferred call panics. Check err first, then defer the close.
Tip: Test client code with net/http/httptest: httptest.NewServer starts a real local server with your fake handler, and you point the client at server.URL. No internet needed.

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

HTTP client

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.

go
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>
}
httptest.NewServer gives a real local URL, so this runs without the internet. The rate is a made-up number.

Practice (0/4 done)

Task 1 · Question

What timeout does http.Get use by default?

Task 2 · Question

A server answers 500 Internal Server Error. What does client.Do return?

Task 3 · Question

Which line is the bug? 1: resp, err := client.Get(url) 2: defer resp.Body.Close() 3: if err != nil { 4: return err 5: }

Task 4 · Practice

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

Question 1

1.Why reuse one http.Client instead of creating one per request?

Question 2

2.Is an http.Client safe to use from many goroutines?

Question 3

3.What does io.LimitReader(resp.Body, 1<<20) do?

Question 4

4.When is it safe to retry a request automatically?

Read more (free)

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.

From Loading, errors and slow networks

What must the body of a JSON POST be?Related

Answer: A string made with JSON.stringify

fetch sends text; stringify the object.

From fetch: talking to servers

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.

From HTTP requests, responses and methods

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.

From context: timeouts and cancellation

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

  1. 2 more topics in “Standard library essentials” before its exam: JSON with encoding/json, Files, time and structured logs.
  2. Module 7 exam: Standard library essentials · 14 questions · 25 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, Building a REST API with a database. 46 topics left in the course before the final exam.

← Previous: An HTTP server with net/http