Guides / Tutorials
Download dog breed images with Go
By the Dog Image API team. Updated .
In Go, you can download dog breed images with the standard library alone: net/http to call the API, encoding/json to decode the breed list into structs, and a bearer key for the full-size PNGs. The program below reads the quota header, retries 502 and 503, and stops on 429.
What the program does
Dog Image API is a catalog of AI-generated dog illustrations. Each set shows one dog in nine views on a transparent background. The breed list is public, so decoding it needs no key. A full-size file is a 2048 x 2048 PNG of about 1 to 7 MB, and fetching it needs a key and a plan.
Set the key in the environment of the machine that runs the program. It never goes into a client app.
export DOG_IMAGE_API_KEY="paste-your-key-here"
Make a folder, run go mod init dogs, and save the code below as main.go. It needs Go 1.21 or newer and no dependencies.
The full program
The structs at the top match the JSON names, including default_variant. Go ignores fields a struct does not name, so you can add more later without breaking the decode.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
const base = "https://dogimage.dev"
var client = &http.Client{Timeout: 60 * time.Second}
type View struct {
ID string `json:"id"`
Label string `json:"label"`
}
type Variant struct {
Slug string `json:"slug"`
Name string `json:"name"`
Kind string `json:"kind"`
}
type Breed struct {
Slug string `json:"slug"`
Name string `json:"name"`
Crossbreed bool `json:"crossbreed"`
DefaultVariant string `json:"default_variant"`
Variants []Variant `json:"variants"`
}
type Catalog struct {
Views []View `json:"views"`
Breeds []Breed `json:"breeds"`
}
// errQuotaUsedUp means the monthly count is spent. Retrying will not help.
var errQuotaUsedUp = errors.New("quota_used_up")
// apiError turns a non-200 reply into an error with the API's code and message.
func apiError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
var payload struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &payload) == nil && payload.Error.Code != "" {
return fmt.Errorf("%d %s: %s", resp.StatusCode, payload.Error.Code, payload.Error.Message)
}
return fmt.Errorf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}
func listBreeds() (*Catalog, error) {
resp, err := client.Get(base + "/api/v1/breeds")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, apiError(resp)
}
var catalog Catalog
if err := json.NewDecoder(resp.Body).Decode(&catalog); err != nil {
return nil, err
}
return &catalog, nil
}
// downloadFull saves one full-size PNG and returns the X-Quota-Remaining header.
func downloadFull(key, breed, variant, view, dir string) (string, error) {
url := fmt.Sprintf("%s/api/v1/images/%s/%s/%s?size=full", base, breed, variant, view)
const attempts = 4
for attempt := 1; ; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return "", err
}
switch {
case resp.StatusCode == http.StatusOK:
remaining := resp.Header.Get("X-Quota-Remaining")
err := savePNG(resp.Body, filepath.Join(dir, fmt.Sprintf("%s-%s-%s.png", breed, variant, view)))
resp.Body.Close()
return remaining, err
case resp.StatusCode == http.StatusTooManyRequests:
err := apiError(resp)
resp.Body.Close()
return "", fmt.Errorf("%w (%v)", errQuotaUsedUp, err)
case (resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusServiceUnavailable) && attempt < attempts:
resp.Body.Close()
time.Sleep(time.Duration(1<<attempt) * time.Second) // 2s, 4s, 8s
default:
err := apiError(resp)
resp.Body.Close()
return "", err
}
}
}
// savePNG writes to a temp file first so a cut connection never leaves a half file.
func savePNG(body io.Reader, path string) error {
tmp := path + ".part"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path)
}
func main() {
key := os.Getenv("DOG_IMAGE_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "Set DOG_IMAGE_API_KEY first.")
os.Exit(1)
}
catalog, err := listBreeds()
if err != nil {
fmt.Fprintln(os.Stderr, "listing breeds:", err)
os.Exit(1)
}
known := map[string]Breed{}
for _, b := range catalog.Breeds {
known[b.Slug] = b
}
fmt.Printf("The catalog lists %d breeds and %d views.\n", len(catalog.Breeds), len(catalog.Views))
if err := os.MkdirAll("dogs", 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
wanted := []string{"pug", "beagle", "samoyed"}
views := []string{"front", "left", "head"}
for _, slug := range wanted {
breed, ok := known[slug]
if !ok {
fmt.Fprintln(os.Stderr, "unknown breed slug:", slug)
os.Exit(1)
}
for _, view := range views {
remaining, err := downloadFull(key, breed.Slug, "default", view, "dogs")
if errors.Is(err, errQuotaUsedUp) {
fmt.Println("Stopping:", err)
return
}
if err != nil {
fmt.Fprintf(os.Stderr, "%s %s: %v\n", slug, view, err)
os.Exit(1)
}
if n, err := strconv.Atoi(remaining); err == nil && n < 10 {
fmt.Println("Fewer than 10 images left this month.")
}
fmt.Printf("saved %s %s, remaining this month: %s\n", slug, view, remaining)
}
}
}
Run it with go run .. With a bad key you will see 401 missing_or_invalid_key after the breed count, which is a quick way to confirm the wiring.
Notes on the code
Closing bodies inside a retry loop
A defer resp.Body.Close() inside a loop would hold every response open until the function returns. The code closes each body in the branch that handles it instead. That also frees the connection before the sleep.
Quota header
X-Quota-Remaining is read from a successful reply. On the All images plan the value is unlimited, which strconv.Atoi rejects, so the warning simply does not fire there. X-Quota-Limit and X-Quota-Used come in the same reply.
The three failures
429 quota_used_up:downloadFullwraps the sentinelerrQuotaUsedUpwith%w, andmainchecks it witherrors.Is. The program prints the message and returns, leaving finished files on disk.502 file_unavailableand503 plan_check_failed: waits of 2, 4 and 8 seconds, then the last error is returned. A 502 is not counted against your quota.- Everything else, such as
401,402or404, returns at once with the API's own words. The 404 replies list valid choices. See the errors and quotas reference.
Choosing what to download
The variant default is the breed's main set. Seven breeds have more colors, and their slugs are in each breed's variants. The program reads DefaultVariant from the list, which you can pass instead of the word default when you want the real slug in your file names.
Each distinct full-size image counts once per calendar month, so rerunning the program for files you already hold is free on the API side, though you should still skip them to save bandwidth. See pricing for plan sizes, and the license for what you may do with the files. For the same job in another language, read the Python version or the Node.js version.
Questions
Do I need a third-party package to call Dog Image API from Go?
No. The standard library covers it: net/http for requests, encoding/json for the breed list, and os and io for saving files. There is no official SDK, so the API itself is the contract.
Why use a .part file when saving?
A PNG can be several megabytes, and a cut connection leaves a truncated file. Writing to a .part file and renaming it after the copy succeeds means a file with the final name is always complete.
Does the quota header come back as a number?
It comes back as a string. It holds a number on limited plans and the word unlimited on the All images plan, so parse it with strconv.Atoi and treat a parse error as no limit.
Should the program retry a 429?
No. The 429 code quota_used_up means the monthly count of distinct full-size images is spent. Stop, keep the files you have, and run again next month or on a larger plan.
Next steps
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3