Go’s http.Client is safe for concurrent use and should usually be reused. A zero-value client has no overall timeout, so production code should configure one or rely on request deadlines deliberately.
Create and send a request
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
Closing the body permits connection reuse. For small responses, read it fully; for large or untrusted responses, impose a limit.
Check status before decoding
if res.StatusCode < 200 || res.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(res.Body, 8<<10))
return fmt.Errorf("upstream status %d: %s", res.StatusCode, body)
}
Do not decode an error response into the success type. Limit captured error bodies so an upstream cannot force large allocations or logs.
Send JSON
payload, err := json.Marshal(input)
if err != nil { return err }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
Configure transport carefully
The default transport already pools connections. Clone it before customization rather than constructing an incomplete transport from scratch. Configure dial, TLS, response-header, and idle-connection timeouts based on the service’s latency budget.
Retries are safe only when the operation is idempotent or uses an idempotency key. Retry a narrow set of transient failures with backoff and a total deadline; never create an infinite retry loop.