# Shoutout to http.Client
## http.Client
A fully featured way of making HTTP requests in Go looks like this:
1type metrics interface {
2 RecordDuration(started time.Time)
3 RecordStatus(code int)
4}
5
6func New(metrics metrics) *Client {
7 return &Client{
8 BaseURL: "http://foo.bar",
9 Token: "some token",
10 Metrics: metrics,
11 }
12}
13
14type Client struct {
15 BaseURL string
16 Token string
17 Metrics metrics
18}
19
20func (c *Client) GetData(ctx context.Context, req GetRequest) (GetResponse, error) {
21 tracer := otel.Tracer(packageName)
22
23 spanStartOptions := []trace.SpanStartOption{
24 trace.WithAttributes(netconv.Transport("tcp")),
25 trace.WithAttributes(httpconv.ClientRequest(request)...),
26 trace.WithAttributes(semconv.HTTPRouteKey.String(request.URL.Path)),
27 trace.WithSpanKind(trace.SpanKindClient),
28 }
29
30 spanName := fmt.Sprintf("HTTP %s %s", request.Method, request.URL.Path)
31
32 ctxWithTrace, span := tracer.Start(request.Context(), spanName, spanStartOptions...)
33 defer spand.End()
34
35 ctx = ctxWithTrace
36
37 defer c.Metrics.RecordDuration(time.Now())
38
39 ctx, cancel := context.WithTimeout(ctx, time.Second)
40 defer cancel()
41
42 for i := range 3 {
43 marshaledRequest, err := json.Marshal(req)
44 if err != nil {
45 return GetResponse{}, err
46 }
47
48 httpRequest, err := http.NewRequestWithContext(
49 ctx,
50 http.MethodGet,
51 c.BaseURL+"/get"+"?page=10&amount=5",
52 bytes.NewReader(marshaledRequest),
53 )
54 if err != nil {
55 return GetResponse{}, err
56 }
57
58 httpRequest.Header.Add("Authorization", c.Token)
59 httpRequest.Header.Add("X-Request-ID", "generated")
60
61 httpResponse, err := http.DefaultClient.Do(httpRequest)
62 if err != nil {
63 c.Metrics.RecordStatus(http.StatusBadGateway)
64 return GetResponse{}, err
65 }
66 defer httpResponse.Body.Close()
67
68 c.Metrics.RecordStatus(httpResponse.StatusCode)
69
70 body, err := io.ReadAll(httpResponse.Body)
71 if err != nil {
72 return GetResponse{}, err
73 }
74
75 switch httpResponse.StatusCode {
76 case http.StatusOK:
77 default:
78 return GetResponse{}, fmt.Errorf(
79 "api returned bad status code=%v body=%s",
80 httpResponse.StatusCode,
81 body,
82 )
83 }
84
85 var buf GetResponse
86
87 if err := json.Unmarshal(body, &buf); err != nil {
88 return GetResponse{}, err
89 }
90
91 return buf, nil
92 }
93
94
95 return buf, nil
96}
One might argue that it's an unreasonable amount of code for a single HTTP request in a SaaS invoicing microservice, or that nobody would ever copy-paste the whole thing whenever it's necessary to implement another API call.
I regularly stumble across such snippets of code in a monorepo.
Sometimes it's a skill issue.
Sometimes the Go community tends to be ok with writing more boilerplate.
Sometimes these fancy parts are not necessary.
At the same time, I see folks from other ecosystems using dedicated libraries early on with batteries included, or implementing own helpers that reflect specific APIs.
If you follow this route in Go, you can use go-resty to wrap metrics, data encoding/decoding, retries, and more with helper methods.
But it's one of the cases where Go standard library shines. An http.RoundTripper that is accepted by http.Client has reasonably generic signature to extend client logic in a composable and maintainable way:
1type RoundTripper interface {
2 RoundTrip(*Request) (*Response, error)
3}
After writing some boilerplate for chaining transports and implementing generic options, you are left with surprisingly concise code:
1func New(metrics metrics) *Client {
2 return &Client{
3 baseURL: "https://foo.bar"
4 httpClient: xhttp.New(
5 xhttp.WithTimeout(time.Second),
6 xhttp.WithRetry(2),
7 xhttp.WithMetrics(metrics),
8 xhttp.WithTracing(),
9 xhttp.WithHeaders("Authorization", "token"),
10 xhttp.WithStatusCheck(xhttp.FailOnNonOK),
11 )
12 }
13}
14
15type Client struct {
16 baseURL string
17 httpClient *http.Client
18}
19
20func (c *Client) GetData(ctx context.Context, req GetRequest) (buf GetResponse, _ error) {
21 url := generics.Must(url.Parse(c.BaseURL+"/get"))
22
23 url.RawQuery = url.Values{
24 "page": {strconv.Itoa(10)},
25 "amount": {strconv.Itoa(5)},
26 }.Encode()
27
28 httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), xhttp.JSON(req))
29 if err != nil {
30 return buf, err
31 }
32
33 httpResponse, err := c.httpClient.Do(httpRequest)
34 if err != nil {
35 return buf, err
36 }
37 defer httpResponse.Body.Close()
38
39 if err := json.NewDecoder(resp.Body).Decode(&buf); err != nil {
40 return buf, err
41 }
42
43 return buf, nil
44}
It's nothing special, but it is a common way to extend the http library. It doesn't solve anything that resty can't solve. However, it looks just the same as the code you would write without being aware of the additional logic that needs to be considered.
I don't plan to dive deep into implementing all the middleware and boilerplate, but I want to focus on the things that my team and I had to figure out.
## Payload
NewRequestWithContext is the recommended way of initializing requests with context.Context, but it doesn't do much beyond basic validation.
You can make use of this stage if you abstract data encoding:
1var _ io.Reader = &JSONReader{}
2
3func JSON(v any) *JSONReader {
4 return &JSONReader{
5 once: &sync.Once{},
6 val: v,
7 inner: bytes.Reader{},
8 err: nil,
9 }
10}
11
12type JSONReader struct {
13 once *sync.Once
14
15 val any
16 inner bytes.Reader
17 err error
18}
19
20func (r *JSONReader) Read(p []byte) (n int, err error) {
21 r.once.Do(func() {
22 switch raw, err := json.Marshal(r.val); {
23 case err != nil:
24 r.err = errors.Wrap(err, "marshal value from reader")
25 default:
26 r.inner = *bytes.NewReader(raw)
27 }
28 })
29
30 if r.err != nil {
31 return 0, err
32 }
33
34 return r.inner.Read(p)
35}
I'm not sure if you need to make this Reader thread-safe, but it doesn't hurt.
## Errors
Another thing I love about moving logic to RoundTripper is error handling:
1type checkFunc func(*http.Response) (bool, error)
2
3type statusCheckTripper struct {
4 next http.RoundTripper
5 f checkFunc
6}
7
8func (s *statusCheckTripper) RoundTrip(r *http.Request) (*http.Response, error) {
9 resp, err := s.next.RoundTrip(r)
10 if err != nil {
11 return nil, err
12 }
13
14 if failed, err := s.f(resp); failed {
15 return nil, err
16 }
17
18 return resp, nil
19}
20
21func FailOnNonOK(resp *http.Response) (bool, error) {
22 if resp.StatusCode == 200 {
23 return false, nil
24 }
25
26 return true, getErrorFromResponse(resp)
27}
28
29func getErrorFromResponse(response *http.Response) error {
30 content, err := io.ReadAll(response.Body)
31 if err != nil {
32 return err
33 }
34
35 return errors.Errorf("returned bad http code=%d with body=%s for request with url=%s",
36 response.StatusCode,
37 content,
38 response.Request.URL.String(),
39 )
40}
This gives you a reliable way to deal with errors in a single error check and provides predictable error messages if you use the same error handlers across the codebase.
If you write structured logs that work well with your error tokenization and aggregate errors by name, then you can conveniently check underlying errors of type url.Error by putting this in a low-level handler to avoid getting non-unique messages:
1func Err(err error) error {
2 if err == nil {
3 return nil
4 }
5
6 var urlErr *url.Error
7 if errors.As(err, &urlErr) {
8 return errors.Wrapf(urlErr.Err, "op=%s url=%s", urlErr.Op, urlErr.URL)
9 }
10
11 return err
12}
## URL
Utilizing the url library to define query params prevents unsafe string concatenations and ensures that each param is properly escaped:
1url.RawQuery = url.Values{
2 "page": {strconv.Itoa(10)},
3 "amount": {strconv.Itoa(5)},
4}.Encode()
Query param values are indeed slices of strings, but it plays nicelly with type inference.
This might also be useful when editing external URLs:
1parsedURL, _ := url.Parse(externalURL)
2uv := parsedURL.Query()
3
4uv.Set("amount", "6")
5
6parsedURL.RawQuery = uv.Encode()
7
8externalURL = parsedURL.String()
## http.Handler
I love http.RoundTripper as much as I hate http.Handler.
1type Handler interface {
2 ServeHTTP(ResponseWriter, *Request)
3}
Unlike its client counterpart, it doesn't inlcude error in its signature.
You are left with the rw.WriteHeader(http.StatusInternalServerError) way of handling errors that loses structural error information across middlewares.
You can figure out how to bypass this limitation by propagating the real error value together with its serialized representation in a body or header, but you still have to write helper methods for exiting to ensure the same error flow across the codebase, while http.Handler doesn't have a return value, so you have to return manually.
Having return value for http.Handler would play nicelly with early-return style of programming, common in Go, overall.
1mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) error {
2 if strings.Trim(r.URL.Path, "/") != "" {
3 return http.Redirect(w, r, "/404", http.StatusMovedPermanently)
4 }
5})
This would require the standard library to have an opinionated top-level handler for errors with an error message like Unexpected error: %msg.
But I don't think that it's something you have to strictly avoid, as it's the default behavior of many libraries and proxies.
Maybe http.Handler was never intended to be used as a high-level API for defining HTTP routes, leaving you with the necessity to use a third-party library or implement your own wrapper. However, I would like to see a new signature in http/v2 so that we could have a community standard, similar to the ones for io or HTTP clients.