package tmdb import ( "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/pkg/errors" ) const baseURL string = "https://api.themoviedb.org" const apiVer string = "3" const ( maxRetries = 3 // Maximum number of retry attempts for 429 responses initialBackoff = 1 * time.Second // Initial backoff duration maxBackoff = 32 * time.Second // Maximum backoff duration ) // requestURL builds a clean API URL from path segments. // Example: requestURL("movie", "550") -> "https://api.themoviedb.org/3/movie/550" // Example: requestURL("search", "movie") -> "https://api.themoviedb.org/3/search/movie" func requestURL(pathSegments ...string) string { path := strings.Join(pathSegments, "/") return fmt.Sprintf("%s/%s/%s", baseURL, apiVer, path) } // buildURL is a convenience function that builds a URL with query parameters. // Example: buildURL([]string{"search", "movie"}, map[string]string{"query": "Inception", "page": "1"}) func buildURL(pathSegments []string, params map[string]string) string { baseURL := requestURL(pathSegments...) if params == nil { params = map[string]string{} } params["language"] = "en-US" values := url.Values{} for key, val := range params { values.Add(key, val) } return fmt.Sprintf("%s?%s", baseURL, values.Encode()) } // get performs a GET request to the TMDB API with proper authentication headers // and automatic retry logic with exponential backoff for rate limiting (429 responses). // // The TMDB API has rate limits around 40 requests per second. This function // implements a courtesy backoff mechanism that: // - Retries up to maxRetries times on 429 responses // - Uses exponential backoff: 1s, 2s, 4s, 8s, etc. (up to maxBackoff) // - Returns an error if max retries are exceeded // // The url parameter should be the full URL (can be built using requestURL or buildURL). func (api *API) get(url string) ([]byte, error) { backoff := initialBackoff for attempt := 0; attempt <= maxRetries; attempt++ { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, errors.Wrap(err, "http.NewRequest") } req.Header.Add("accept", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", api.token)) res, err := http.DefaultClient.Do(req) if err != nil { return nil, errors.Wrap(err, "http.DefaultClient.Do") } // Check for rate limiting (429 Too Many Requests) if res.StatusCode == http.StatusTooManyRequests { res.Body.Close() // If we've exhausted retries, return an error if attempt >= maxRetries { return nil, errors.New("rate limit exceeded: maximum retries reached") } // Check for Retry-After header first (respect server's guidance) if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" { if duration, err := time.ParseDuration(retryAfter + "s"); err == nil { backoff = duration } } // Apply exponential backoff: 1s, 2s, 4s, 8s, etc. if backoff > maxBackoff { backoff = maxBackoff } time.Sleep(backoff) // Double the backoff for next iteration backoff *= 2 continue } // For other error status codes, return an error if res.StatusCode != http.StatusOK { return nil, errors.Errorf("unexpected status code: %d", res.StatusCode) } // Success - read and return body body, err := io.ReadAll(res.Body) if err != nil { return nil, errors.Wrap(err, "io.ReadAll") } return body, nil } return nil, errors.Errorf("max retries (%d) exceeded due to rate limiting (HTTP 429)", maxRetries) }