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