Go REST

Go (net/http)

Idiomatic net/http with request contexts, struct decoding, and back-off on 429.

Go shipsnet/http with everything you need to call a JSON API in production: configurable transports, request contexts for cancellation and deadlines, and a streaming body reader that plays nicely withencoding/json. You almost never need a third-party client. This guide walks through the Go REST endpoints with patterns you can copy into a real service.

Setup

Define a small client struct that holds the base URL, the access token, and a long-lived*http.Client. Reusing the client is important; every freshhttp.Client opens new TCP+TLS connections, which adds 100+ ms per request to a public API.

package gorest

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "strconv"
    "time"
)

type Client struct {
    BaseURL string
    Token   string
    HTTP    *http.Client
}

func New(token string) *Client {
    return &Client{
        BaseURL: "https://gorest.co.in/public/v2",
        Token:   token,
        HTTP:    &http.Client{Timeout: 15 * time.Second},
    }
}

Send the bearer token

Wrap the request creation so you cannot forget theAuthorization header. Always pass a context; even an immediate timeout from the caller is useful when the upstream is slow.

func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) {
    var rdr io.Reader
    if body != nil {
        buf, err := json.Marshal(body)
        if err != nil { return nil, err }
        rdr = bytes.NewReader(buf)
    }
    req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
    if err != nil { return nil, err }
    req.Header.Set("Authorization", "Bearer "+c.Token)
    req.Header.Set("Accept", "application/json")
    if body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
    return req, nil
}

List users

Define a struct that matches the shape the API returns, then decode straight into it. Build the query string withurl.Values rather than concatenating strings.

type User struct {
    ID     int    `json:"id"`
    Name   string `json:"name"`
    Email  string `json:"email"`
    Gender string `json:"gender"`
    Status string `json:"status"`
}

func (c *Client) ListUsers(ctx context.Context, status string, page int) ([]User, int, error) {
    q := url.Values{}
    q.Set("status", status)
    q.Set("page", strconv.Itoa(page))
    req, err := c.newRequest(ctx, "GET", "/users?"+q.Encode(), nil)
    if err != nil { return nil, 0, err }
    res, err := c.HTTP.Do(req)
    if err != nil { return nil, 0, err }
    defer res.Body.Close()
    if res.StatusCode != http.StatusOK {
        return nil, 0, fmt.Errorf("list users: %s", res.Status)
    }
    var users []User
    if err := json.NewDecoder(res.Body).Decode(&users); err != nil {
        return nil, 0, err
    }
    totalPages, _ := strconv.Atoi(res.Header.Get("X-Pagination-Pages"))
    return users, totalPages, nil
}

Fetch a single user

var ErrNotFound = fmt.Errorf("not found")

func (c *Client) GetUser(ctx context.Context, id int) (*User, error) {
    req, err := c.newRequest(ctx, "GET", fmt.Sprintf("/users/%d", id), nil)
    if err != nil { return nil, err }
    res, err := c.HTTP.Do(req)
    if err != nil { return nil, err }
    defer res.Body.Close()
    if res.StatusCode == http.StatusNotFound { return nil, ErrNotFound }
    if res.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("get user %d: %s", id, res.Status)
    }
    var u User
    return &u, json.NewDecoder(res.Body).Decode(&u)
}

Create, update, delete

The three writes use POST, PATCH and DELETE. Use a separate struct for the request body so you do not accidentally send fields the API does not expect (id in particular).

type UserInput struct {
    Name   string `json:"name"`
    Email  string `json:"email"`
    Gender string `json:"gender"`
    Status string `json:"status"`
}

func (c *Client) CreateUser(ctx context.Context, in UserInput) (*User, error) {
    req, err := c.newRequest(ctx, "POST", "/users", in)
    if err != nil { return nil, err }
    res, err := c.HTTP.Do(req)
    if err != nil { return nil, err }
    defer res.Body.Close()
    if res.StatusCode != http.StatusCreated {
        return nil, fmt.Errorf("create user: %s", res.Status)
    }
    var u User
    return &u, json.NewDecoder(res.Body).Decode(&u)
}

A 422 carries an array of validation errors. Decode it into a slice and bubble it up as a typed error so your callers can branch on it:

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}
type ValidationError struct{ Errors []FieldError }

func (v ValidationError) Error() string {
    return fmt.Sprintf("validation: %d field(s) failed", len(v.Errors))
}

// ... inside CreateUser, before the 201 check:
if res.StatusCode == http.StatusUnprocessableEntity {
    var errs []FieldError
    if err := json.NewDecoder(res.Body).Decode(&errs); err != nil {
        return nil, err
    }
    return nil, ValidationError{Errors: errs}
}

Retry on rate limits

Implement a small back-off in your transport rather than every caller. Use a customhttp.RoundTripper so retries are transparent. TheX-RateLimit-Reset header is in seconds:

type retryTransport struct{ base http.RoundTripper }

func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    for attempt := 0; attempt < 3; attempt++ {
        res, err := t.base.RoundTrip(req)
        if err != nil { return nil, err }
        if res.StatusCode != http.StatusTooManyRequests { return res, nil }
        wait, _ := strconv.Atoi(res.Header.Get("X-RateLimit-Reset"))
        if wait < 1 { wait = 1 }
        res.Body.Close()
        select {
        case <-time.After(time.Duration(wait) * time.Second):
        case <-req.Context().Done():
            return nil, req.Context().Err()
        }
    }
    return nil, fmt.Errorf("rate limited; gave up")
}

// wire it on the client:
c.HTTP.Transport = &retryTransport{base: http.DefaultTransport}

Tips

Related guides

Keep going

Back to all guides Try it in the console