Files
CLIProxyAPI/internal/pluginstore/github_rate_limit.go

191 lines
5.5 KiB
Go

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