package indexer import ( "errors" "fmt" "net/http" "strconv" "time" ) // RateLimited is a source answering 429. Named so the search pacing can hold // that source back for as long as it asked; still an ErrUnreachable to // everything else, because this search did not reach it. type RateLimited struct { // RetryAfter is what the source asked for; zero when it did not say. RetryAfter time.Duration // Who names the source in the sentence, e.g. "Prowlarr". Who string } func (e *RateLimited) Error() string { return fmt.Sprintf("%v: HTTP 429, %s is rate-limiting this search", ErrUnreachable, e.Who) } func (e *RateLimited) Unwrap() error { return ErrUnreachable } // AsRateLimited finds a RateLimited anywhere in err's chain. func AsRateLimited(err error) (*RateLimited, bool) { var rl *RateLimited if errors.As(err, &rl) { return rl, true } return nil, false } // RetryAfter reads a Retry-After header: seconds, or an HTTP date. Zero when // absent or unreadable. func RetryAfter(h http.Header, now time.Time) time.Duration { v := h.Get("Retry-After") if v == "" { return 0 } if s, err := strconv.Atoi(v); err == nil && s > 0 { return time.Duration(s) * time.Second } if t, err := http.ParseTime(v); err == nil && t.After(now) { return t.Sub(now) } return 0 }