190 lines · 5.5 KB · Go
RawDownload
package pluginstore import (	"encoding/json"	"fmt"	"net/http"	"net/url"	"strconv"	"strings"	"sync"	"time") // RateLimitError reports a GitHub API cooldown without exposing response bodies// or credentials. RetryAt is also populated for requests blocked locally.type RateLimitError struct {	StatusCode int	RetryAt    time.Time} func (err *RateLimitError) Error() string {	return fmt.Sprintf("GitHub API rate limited; retry after %s", err.RetryAt.UTC().Format(time.RFC3339))} // RetryAfterSeconds rounds up so clients never retry before the reset time.func (err *RateLimitError) RetryAfterSeconds(now time.Time) int64 {	delay := err.RetryAt.Sub(now)	if delay <= 0 {		return 0	}	seconds := int64(delay / time.Second)	if delay%time.Second != 0 {		seconds++	}	return seconds} type githubCooldown struct {	retryAt  time.Time	status   int	failures uint8} // GitHubRateLimiter shares cooldowns across repositories, release metadata and// API asset downloads. Its zero value is ready for use. Clients without an// explicit limiter share the process-wide default.type GitHubRateLimiter struct {	mu          sync.Mutex	entries     map[string]githubCooldown	nextPruneAt time.Time	nowFunc     func() time.Time} var defaultGitHubRateLimiter GitHubRateLimiter func (c Client) githubRateLimiter() *GitHubRateLimiter {	if c.RateLimiter != nil {		return c.RateLimiter	}	return &defaultGitHubRateLimiter} func (limiter *GitHubRateLimiter) now() time.Time {	if limiter.nowFunc != nil {		return limiter.nowFunc()	}	return time.Now()} func githubRateLimitKey(requestURL, networkScope string, headers http.Header, authenticated bool) string {	parsed, errParse := url.Parse(requestURL)	if errParse != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "api.github.com") || (parsed.Port() != "" && parsed.Port() != "443") {		return ""	}	return "api.github.com/" + requestIdentity(networkScope, headers, authenticated)} func (limiter *GitHubRateLimiter) check(key string) error {	if key == "" {		return nil	}	limiter.mu.Lock()	defer limiter.mu.Unlock()	now := limiter.now()	limiter.pruneLocked(now)	entry := limiter.entries[key]	if now.Before(entry.retryAt) {		return &RateLimitError{StatusCode: entry.status, RetryAt: entry.retryAt}	}	return nil} // pruneLocked drops inactive identities after a grace period so credential and// proxy rotation cannot leave cooldown state behind indefinitely. Retaining// recently expired entries preserves secondary-limit backoff across retries.func (limiter *GitHubRateLimiter) pruneLocked(now time.Time) {	if now.Before(limiter.nextPruneAt) {		return	}	for key, entry := range limiter.entries {		if !now.Before(entry.retryAt.Add(time.Hour)) {			delete(limiter.entries, key)		}	}	limiter.nextPruneAt = now.Add(time.Hour)} // observe records even successful responses that consume the final request.// A late success must not clear a cooldown established by another request.func (limiter *GitHubRateLimiter) observe(key string, status int, headers http.Header, body []byte) error {	if key == "" {		return nil	}	limiter.mu.Lock()	defer limiter.mu.Unlock()	now := limiter.now()	limiter.pruneLocked(now)	entry := limiter.entries[key]	remainingZero := strings.TrimSpace(headers.Get("X-RateLimit-Remaining")) == "0"	retryAt := githubRetryAfter(headers.Get("Retry-After"), now)	rateLimited := status == http.StatusTooManyRequests || (status == http.StatusForbidden &&		(remainingZero || !retryAt.IsZero() || githubRateLimitMessage(body)))	if !rateLimited && !remainingZero {		if status >= http.StatusOK && status < http.StatusMultipleChoices && !now.Before(entry.retryAt) {			delete(limiter.entries, key)		}		return nil	}	if remainingZero {		if reset, errReset := strconv.ParseInt(strings.TrimSpace(headers.Get("X-RateLimit-Reset")), 10, 64); errReset == nil && reset > 0 {			if resetAt := time.Unix(reset, 0); resetAt.After(retryAt) {				retryAt = resetAt			}		}	}	if !retryAt.After(now) {		if !rateLimited {			return nil		}		// Headerless secondary limits start at one minute and back off up to an		// hour. Only an actual upstream rejection increments this counter.		delay := time.Minute * time.Duration(1<<entry.failures)		if delay > time.Hour {			delay = time.Hour		}		retryAt = now.Add(delay)		if entry.failures < 6 {			entry.failures++		}	}	if retryAt.After(entry.retryAt) {		entry.retryAt = retryAt	}	entry.status = status	if !rateLimited {		entry.status = http.StatusTooManyRequests	}	if limiter.entries == nil {		limiter.entries = make(map[string]githubCooldown)	}	limiter.entries[key] = entry	if rateLimited {		return &RateLimitError{StatusCode: status, RetryAt: entry.retryAt}	}	return nil} func githubRetryAfter(value string, now time.Time) time.Time {	value = strings.TrimSpace(value)	if seconds, errSeconds := strconv.ParseInt(value, 10, 64); errSeconds == nil && seconds >= 0 && seconds <= int64((1<<63-1)/time.Second) {		return now.Add(time.Duration(seconds) * time.Second)	}	if retryAt, errDate := http.ParseTime(value); errDate == nil {		return retryAt	}	return time.Time{}} func githubRateLimitMessage(body []byte) bool {	var message struct {		Message string `json:"message"`	}	if errDecode := json.Unmarshal(body, &message); errDecode != nil {		return false	}	text := strings.ToLower(message.Message)	return strings.Contains(text, "secondary rate limit") ||		strings.Contains(text, "api rate limit exceeded") ||		strings.Contains(text, "abuse detection")}