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

GO
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))
}
Output
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

GO
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)
}
Output
&{Lisbon 23.5} <nil>
<nil> weather for atlantis: unexpected status 404 Not Found

POST 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:

GO
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))
}
Output
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):

GO
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())
}
Output
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:

GO
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)
}
Output
[/a /b /c /d] true

Checklist

  • Use a shared http.Client with a Timeout; don't use the default client in production code.
  • defer resp.Body.Close() as soon as you've checked err.
  • 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.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
[go rust] 2 <nil>
Next: building command-line tools.