# 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](https://amodb.dev/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:

```bash
export AMODB_KEY="YOUR_API_KEY"
```

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

## Step 2: Create the module

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

```bash
mkdir topfilms && cd topfilms
go mod init topfilms
go version
```

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

## 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](https://amodb.dev/genres/). This complete program only prints the status line.

```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)
}
```

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

## 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.

```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"`
}
```

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](https://amodb.dev/discover/).

## 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:

```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
}
```

See [errors](https://amodb.dev/errors/) for every code and [rate limits](https://amodb.dev/rate-limits/) for the sliding window.

## 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`.

```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)
	}
}
```

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.

## 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](https://amodb.dev/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](https://amodb.dev/java-movie-api/), or read the [quickstart](https://amodb.dev/quickstart/).

## Questions and answers

### Which Go version does the code need?

Go 1.22 or newer. It uses the built-in min function from Go 1.21, the any alias and only the standard library, so go.mod lists no dependencies and go run . works right after go mod init.

### Why use typed structs instead of map[string]any?

Structs give you compile time field names and real numeric types, and encoding/json skips fields you did not declare. That suits an API where new fields can appear over time, because older code keeps working.

### How does the client wait on a 429?

It reads the Retry-After header as whole seconds, falls back to 1 when the header is missing, caps the wait at 30 and makes at most 3 attempts in total. After that it returns the status message to the caller.

### Can I send requests from several goroutines?

Yes, but every call still counts toward the 20, 40 or 120 requests per minute of your key. Spread calls with a time.Ticker or a buffered channel and let the 429 retry catch the occasional spill over.

### Where does the tool keep its cache?

In a folder named amodb-top-films under os.UserCacheDir, with one file per request named from a SHA-256 hash of the URL. A file is reused for 30 minutes, the same time the edge holds discover lists.

## Related pages

- [Discover API](https://amodb.dev/discover/): Discover API with the same filters as TMDB v3. List movies and TV shows by genre, cast, company, keyword, year, rating, runtime and provider, with sorting.
- [Genres and lookup lists](https://amodb.dev/genres/): Genre list API with TMDB v3 paths: movie and TV genre ids with translated names, plus countries, languages, jobs, certifications and watch provider lists.
- [Rate limits](https://amodb.dev/rate-limits/): amodb limits each key by requests per minute in a sliding 60 second window: 20, 40 or 120 by plan. Read the rate limit headers and handle 429 with Retry-After.
- [Errors](https://amodb.dev/errors/): Every amodb error with its HTTP status, TMDB status_code and message, plus the OMDb error shape. What each one means and what your client should do next.
- [Java movie API tutorial](https://amodb.dev/java-movie-api/): Build a Java episode guide with the JDK HttpClient and Jackson records: fetch a season, honor Retry-After on a 429 and cache replies in files. Java 17 or newer.
- [PHP movie API tutorial](https://amodb.dev/php-movie-api/): Build a PHP function and a small movie page that call the amodb API with cURL, retry on 429, escape output and cache every reply in a file. PHP 8.2 or newer.

Updated: 2026-10-18. Source: https://amodb.dev/go-movie-api/
