Go movie API tutorial

Build a Go command line tool that lists the best-rated movies of a genre and release year. It decodes JSON into typed structs, retries a 429 after the Retry-After delay and caches replies on disk. It uses only the standard library, needs Go 1.22 or newer and takes about 20 minutes.

.go Go Updated View as Markdown

You will build topfilms, a Go command line tool. Run go run . -genre 878 -year 2010 -n 10 and it prints the ten best-rated science fiction movies released in 2010, with score and vote count. It uses typed structs, only the standard library and Go 1.22 or newer. Plan on 20 minutes.

Step 1: Get an API key

Start on pricing, where you choose a prepaid period and one of the 20, 40 or 120 requests per minute plans. Then create a key in your account dashboard and export it:

The program reads it with os.Getenv("AMODB_KEY"), so it never sits in the source.

Shell
export AMODB_KEY="YOUR_API_KEY"

Step 2: Create the module

Make a folder and initialize a module. There is nothing to download.

go version should print 1.22 or higher. Put all code below in a file named main.go.

Shell
mkdir topfilms && cd topfilms
go mod init topfilms
go version

Step 3: Send the first request

The tool calls GET /3/discover/movie, which filters and sorts the catalog. Genre 878 is Science Fiction, and the full id list is on genres. This complete program only prints the status line.

Never use http.DefaultClient for API calls, because it has no timeout.

Go
package main

import (
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	url := "https://api.amodb.dev/3/discover/movie?with_genres=878"
	req, _ := http.NewRequest(http.MethodGet, url, nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AMODB_KEY"))
	res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}

Step 4: Decode into typed structs

Declare only the fields you read. encoding/json ignores the rest, so new fields in the reply cannot break the tool.

The query asks for sort_by=vote_average.desc and vote_count.gte=500. Without the vote floor, titles with a single 10 star vote would lead the list. Every filter is documented on discover.

Go
type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}

Step 5: Retry on 429 and report errors

Read the body once, then decide by status code.

StatusWhat the client does
200Writes the cache file and decodes the body.
401Returns an error that names AMODB_KEY. No retry, because the next call would fail too.
429Sleeps for Retry-After seconds (1 if missing, 30 at most) and tries again, up to 3 attempts.
Other 4xx and 5xxReturns the status_code and status_message from the TMDB error body.
Transport errorReturns network error wrapped with %w.

The delay helper is small:

See errors for every code and rate limits for the sliding window.

Go
func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}

Step 6: Cache replies on disk

The edge holds discover lists for 30 minutes. The client saves each successful body in the user cache folder, named by the SHA-256 of the full URL, and serves it again while the file is younger than the TTL. A repeated command then makes no request.

Step 7: The complete program

Replace main.go with this and run go run . -genre 878 -year 2010 -n 10.

Try other filters with the flags. go run . -genre 18 -year 1999 -n 5 lists the five best-rated dramas from 1999. Run the same command twice and the second run answers from the cache file without touching the network, until 30 minutes have passed. If no movie has 500 votes for your combination, the tool prints No movies matched. and exits normally.

The retry loop returns from inside the switch in every case except a 429 that still has attempts left. That is why the function ends with a plain error: the compiler needs a return after the loop, and the message would only appear if the loop logic were changed.

Go
package main

import (
	"crypto/sha256"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

const baseURL = "https://api.amodb.dev/3"

type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}

type apiError struct {
	StatusCode    int    `json:"status_code"`
	StatusMessage string `json:"status_message"`
}

type Client struct {
	key      string
	hc       *http.Client
	cacheDir string
}

func NewClient(key string) (*Client, error) {
	base, err := os.UserCacheDir()
	if err != nil {
		base = os.TempDir()
	}
	dir := filepath.Join(base, "amodb-top-films")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	return &Client{key: key, hc: &http.Client{Timeout: 10 * time.Second}, cacheDir: dir}, nil
}

func (c *Client) Get(path string, query url.Values, ttl time.Duration, out any) error {
	endpoint := baseURL + path + "?" + query.Encode()
	cacheFile := filepath.Join(c.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256([]byte(endpoint))))
	if info, err := os.Stat(cacheFile); err == nil && time.Since(info.ModTime()) < ttl {
		if body, err := os.ReadFile(cacheFile); err == nil {
			return json.Unmarshal(body, out)
		}
	}

	for attempt := 1; attempt <= 3; attempt++ {
		req, err := http.NewRequest(http.MethodGet, endpoint, nil)
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+c.key)
		res, err := c.hc.Do(req)
		if err != nil {
			return fmt.Errorf("network error: %w", err)
		}
		body, err := io.ReadAll(res.Body)
		res.Body.Close()
		if err != nil {
			return fmt.Errorf("reading reply: %w", err)
		}

		switch {
		case res.StatusCode == http.StatusOK:
			_ = os.WriteFile(cacheFile, body, 0o644)
			return json.Unmarshal(body, out)
		case res.StatusCode == http.StatusTooManyRequests && attempt < 3:
			time.Sleep(retryDelay(res.Header.Get("Retry-After")))
		default:
			return statusError(res.StatusCode, body)
		}
	}
	return errors.New("retries exhausted")
}

func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}

func statusError(status int, body []byte) error {
	if status == http.StatusUnauthorized {
		return errors.New("401: the key is invalid or suspended, check AMODB_KEY")
	}
	var e apiError
	if json.Unmarshal(body, &e) == nil && e.StatusMessage != "" {
		return fmt.Errorf("amodb returned %d (status_code %d): %s", status, e.StatusCode, e.StatusMessage)
	}
	return fmt.Errorf("amodb returned %d", status)
}

func yearOf(date string) string {
	if len(date) >= 4 {
		return date[:4]
	}
	return "n/a"
}

func main() {
	genre := flag.Int("genre", 878, "genre id, 878 is Science Fiction")
	year := flag.Int("year", 2010, "primary release year")
	limit := flag.Int("n", 10, "how many movies to print, 1 to 20")
	flag.Parse()

	key := os.Getenv("AMODB_KEY")
	if key == "" {
		fmt.Fprintln(os.Stderr, "Set AMODB_KEY first.")
		os.Exit(1)
	}
	client, err := NewClient(key)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	query := url.Values{}
	query.Set("language", "en-US")
	query.Set("with_genres", strconv.Itoa(*genre))
	query.Set("primary_release_year", strconv.Itoa(*year))
	query.Set("sort_by", "vote_average.desc")
	query.Set("vote_count.gte", "500")

	var page DiscoverPage
	if err := client.Get("/discover/movie", query, 30*time.Minute, &page); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	if len(page.Results) == 0 {
		fmt.Println("No movies matched.")
		return
	}
	fmt.Printf("%d movies match, best first:\n", page.TotalResults)
	for i, m := range page.Results {
		if i >= *limit {
			break
		}
		fmt.Printf("%2d. %s (%s) %.1f from %d votes\n", i+1, m.Title, yearOf(m.ReleaseDate), m.VoteAverage, m.VoteCount)
	}
}

Next steps

  • Add a -page flag and pass it as the page parameter to walk further down the list.
  • Resolve each movie id with movies and add append_to_response=ratings to read the IMDb score from ratings.imdb.value.
  • Run several genres at once with goroutines, and keep them under your plan's requests per minute.
  • Port the same idea to Java, or read the quickstart.