HTTP Clients
Calling web APIs is one of the most common jobs in Go programs. The net/http package's client sends requests, follows redirects, reuses connections and handles TLS. This lesson covers GET and POST requests, checking status codes, decoding JSON responses, headers, timeouts and cancellation — and the one line people most often forget: closing the response body.
A local server for the examples
So the examples run anywhere without depending on a real website, each one starts a small test server with httptest.NewServer. It listens on a real local port, so the client code is exactly what you'd use against any URL — just replace srv.URL with an address such as https://api.example.com.
A GET request
package main import ( "fmt" "io" "net/http" "net/http/httptest" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "pong") })) defer srv.Close() resp, err := http.Get(srv.URL + "/ping") if err != nil { fmt.Println("request failed:", err) return } defer resp.Body.Close() // always close the body body, err := io.ReadAll(resp.Body) if err != nil { fmt.Println("reading body:", err) return } fmt.Println(resp.StatusCode, resp.Status, resp.Header.Get("Content-Type")) fmt.Println(string(body)) }
200 200 OK text/plain; charset=utf-8 pong
Two kinds of failure are possible. An err means the request couldn't be completed at all — DNS failure, connection refused, timeout. A response with a status like 404 or 500 is not an error to the client; you must check StatusCode yourself. And whatever happens, if you got a response, close its body, or the connection can't be reused and your program slowly leaks resources.
Checking status and decoding JSON
package main import ( "encoding/json" "fmt" "net/http" "net/http/httptest" ) type Weather struct { City string `json:"city"` TempC float64 `json:"temp_c"` } func getWeather(baseURL, city string) (*Weather, error) { resp, err := http.Get(baseURL + "/weather/" + city) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("weather for %s: unexpected status %s", city, resp.Status) } var w Weather if err := json.NewDecoder(resp.Body).Decode(&w); err != nil { return nil, fmt.Errorf("decoding weather: %w", err) } return &w, nil } func main() { mux := http.NewServeMux() mux.HandleFunc("GET /weather/{city}", func(w http.ResponseWriter, r *http.Request) { if r.PathValue("city") != "lisbon" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"city": "Lisbon", "temp_c": 23.5}`) }) srv := httptest.NewServer(mux) defer srv.Close() w, err := getWeather(srv.URL, "lisbon") fmt.Println(w, err) w, err = getWeather(srv.URL, "atlantis") fmt.Println(w, err) }
&{Lisbon 23.5} <nil>
<nil> weather for atlantis: unexpected status 404 Not FoundPOST with a JSON body and headers
For anything beyond a simple GET, build a request with http.NewRequestWithContext, set headers, and send it with a client's Do method:
package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) fmt.Fprintf(w, "%s %s auth=%q type=%q body=%s", r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type"), body) })) defer srv.Close() payload, _ := json.Marshal(map[string]any{"title": "Buy milk", "priority": 2}) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/tasks", bytes.NewReader(payload)) if err != nil { fmt.Println(err) return } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer token123") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Println(err) return } defer resp.Body.Close() reply, _ := io.ReadAll(resp.Body) fmt.Println(string(reply)) }
POST /tasks auth="Bearer token123" type="application/json" body={"priority":2,"title":"Buy milk"}Query strings are best built with net/url: url.Values{"q": {"go generics"}}.Encode() produces q=go+generics with proper escaping.
Timeouts
The default client has no timeout: if a server never answers, your program waits forever. Create your own client with a Timeout, and reuse it for all requests (clients are safe for concurrent use and keep connections open for reuse):
package main import ( "errors" "fmt" "net/http" "net/http/httptest" "strings" "time" ) var client = &http.Client{Timeout: 100 * time.Millisecond} func main() { slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(500 * time.Millisecond) })) defer slow.Close() _, err := client.Get(slow.URL) fmt.Println(strings.ReplaceAll(err.Error(), slow.URL, "http://slow")) var t interface{ Timeout() bool } fmt.Println("timeout:", errors.As(err, &t) && t.Timeout()) }
Get "http://slow": context deadline exceeded (Client.Timeout exceeded while awaiting headers) timeout: true
The error says what happened, and any error with a Timeout() bool method that returns true — as network errors have — can be recognised as a timeout. (The example replaces the test server's random address in the message so the output is stable.) For a deadline on one particular call — or to cancel a request when the user gives up — pass a context with a timeout to NewRequestWithContext (lesson 56). Inside a server handler, pass r.Context() so outgoing calls stop if the incoming request is cancelled.
Several requests at once
Clients are safe for concurrent use, so fetching several URLs in parallel is a matter of goroutines:
package main import ( "fmt" "io" "net/http" "net/http/httptest" "sync" "time" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(50 * time.Millisecond) fmt.Fprint(w, r.URL.Path) })) defer srv.Close() client := &http.Client{Timeout: 2 * time.Second} paths := []string{"/a", "/b", "/c", "/d"} bodies := make([]string, len(paths)) start := time.Now() var wg sync.WaitGroup for i, p := range paths { wg.Go(func() { resp, err := client.Get(srv.URL + p) if err != nil { bodies[i] = "error: " + err.Error() return } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) bodies[i] = string(b) }) } wg.Wait() fmt.Println(bodies, time.Since(start) < 150*time.Millisecond) }
[/a /b /c /d] true
Checklist
- Use a shared
http.Clientwith aTimeout; don't use the default client in production code. defer resp.Body.Close()as soon as you've checkederr.- Check
resp.StatusCode; HTTP error statuses aren't Go errors. - Limit how much you read from untrusted servers with
io.LimitReader. - Pass a context so requests can be cancelled.
Try it yourself
Write fetchJSON[T any](client *http.Client, url string) (T, error), a generic helper that checks the status and decodes the body into any type.
[go rust] 2 <nil>