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.
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.
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.
mkdir topfilms && cd topfilms
go mod init topfilms
go versionStep 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.
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_ 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.
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.
| Status | What the client does |
|---|---|
| 200 | Writes the cache file and decodes the body. |
| 401 | Returns an error that names AMODB_KEY. No retry, because the next call would fail too. |
| 429 | Sleeps for Retry-After seconds (1 if missing, 30 at most) and tries again, up to 3 attempts. |
| Other 4xx and 5xx | Returns the status_code and status_message from the TMDB error body. |
| Transport error | Returns network error wrapped with %w. |
The delay helper is small:
See errors for every code and rate limits for the sliding window.
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.
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
-pageflag and pass it as thepageparameter to walk further down the list. - Resolve each movie id with movies and add
append_to read the IMDb score fromto_ response= ratings 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.