mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-09 17:42:08 +08:00
342 lines
15 KiB
Go
342 lines
15 KiB
Go
package pluginstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func githubTestHeaders(values map[string]string) http.Header {
|
|
headers := make(http.Header)
|
|
for name, value := range values {
|
|
headers.Set(name, value)
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func TestGitHubRateLimitHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
reset := strconv.FormatInt(now.Add(time.Hour).Unix(), 10)
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
headers map[string]string
|
|
body string
|
|
wait time.Duration
|
|
}{
|
|
{name: "primary forbidden", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}, wait: time.Hour},
|
|
{name: "retry seconds", status: 429, headers: map[string]string{"Retry-After": "120"}, wait: 2 * time.Minute},
|
|
{name: "retry date", status: 429, headers: map[string]string{"Retry-After": now.Add(3 * time.Minute).Format(http.TimeFormat)}, wait: 3 * time.Minute},
|
|
{name: "reset wins", status: 403, headers: map[string]string{"Retry-After": "120", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}, wait: time.Hour},
|
|
{name: "retry wins", status: 429, headers: map[string]string{"Retry-After": "7200", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}, wait: 2 * time.Hour},
|
|
{name: "secondary message", status: 403, body: `{"message":"You have exceeded a secondary rate limit."}`, wait: time.Minute},
|
|
{name: "abuse message", status: 403, body: `{"message":"You have triggered an abuse detection mechanism."}`, wait: time.Minute},
|
|
{name: "retry forbidden", status: 403, headers: map[string]string{"Retry-After": "60"}, wait: time.Minute},
|
|
{name: "headerless too many requests", status: 429, wait: time.Minute},
|
|
{name: "invalid retry", status: 429, headers: map[string]string{"Retry-After": "invalid"}, wait: time.Minute},
|
|
{name: "overflow retry", status: 429, headers: map[string]string{"Retry-After": "9223372036854775807"}, wait: time.Minute},
|
|
{name: "negative retry", status: 429, headers: map[string]string{"Retry-After": "-10"}, wait: time.Minute},
|
|
{name: "past reset", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1"}, wait: time.Minute},
|
|
{name: "invalid reset", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "invalid"}, wait: time.Minute},
|
|
{name: "permission denied", status: 403, body: `{"message":"Resource not accessible by integration"}`},
|
|
{name: "invalid body", status: 403, body: "forbidden"},
|
|
{name: "not found", status: 404},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
|
|
errLimit := limiter.observe("quota", tt.status, githubTestHeaders(tt.headers), []byte(tt.body))
|
|
if tt.wait == 0 {
|
|
if errLimit != nil || limiter.check("quota") != nil {
|
|
t.Fatalf("non-rate-limit response started cooldown: %v", errLimit)
|
|
}
|
|
return
|
|
}
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errLimit, &rateLimit) || rateLimit.StatusCode != tt.status || !rateLimit.RetryAt.Equal(now.Add(tt.wait)) {
|
|
t.Fatalf("error=%v, want status=%d retry=%s", errLimit, tt.status, now.Add(tt.wait))
|
|
}
|
|
if errCheck := limiter.check("quota"); !errors.As(errCheck, &rateLimit) {
|
|
t.Fatalf("check=%v, want cooldown", errCheck)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubRateLimitKeyCanonicalHost(t *testing.T) {
|
|
t.Parallel()
|
|
want := githubRateLimitKey("https://api.github.com/repos/owner/repo", "", nil, false)
|
|
for _, requestURL := range []string{"https://api.github.com:443/repos/other/repo", "https://API.GITHUB.COM/releases"} {
|
|
if got := githubRateLimitKey(requestURL, "", nil, false); got != want || got == "" {
|
|
t.Fatalf("key for %s = %q, want %q", requestURL, got, want)
|
|
}
|
|
}
|
|
for _, requestURL := range []string{"https://api.github.com:444/repos/owner/repo", "http://api.github.com/", "https://api.github.com.example/", "https://raw.githubusercontent.com/"} {
|
|
if got := githubRateLimitKey(requestURL, "", nil, false); got != "" {
|
|
t.Fatalf("unrelated URL %s has GitHub quota key %q", requestURL, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGitHubRateLimitPrunesInactiveIdentities(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
|
|
_ = limiter.observe("old-token", 429, nil, nil)
|
|
_ = limiter.observe("active-token", 429, githubTestHeaders(map[string]string{"Retry-After": "7200"}), nil)
|
|
now = now.Add(time.Hour + time.Minute)
|
|
_ = limiter.check("new-token")
|
|
if _, exists := limiter.entries["old-token"]; exists {
|
|
t.Fatal("inactive credential cooldown was not pruned")
|
|
}
|
|
if errCheck := limiter.check("active-token"); errCheck == nil {
|
|
t.Fatal("active cooldown was pruned")
|
|
}
|
|
}
|
|
|
|
func TestGitHubRateLimitBackoffAndRecovery(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
|
|
for _, delay := range []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute, 32 * time.Minute, time.Hour, time.Hour} {
|
|
errLimit := limiter.observe("quota", 429, nil, nil)
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errLimit, &rateLimit) || !rateLimit.RetryAt.Equal(now.Add(delay)) {
|
|
t.Fatalf("backoff=%v, want %s", errLimit, delay)
|
|
}
|
|
for range 5 {
|
|
if limiter.check("quota") == nil {
|
|
t.Fatal("local check lost cooldown")
|
|
}
|
|
}
|
|
now = now.Add(delay)
|
|
if errCheck := limiter.check("quota"); errCheck != nil {
|
|
t.Fatalf("expired cooldown: %v", errCheck)
|
|
}
|
|
}
|
|
_ = limiter.observe("quota", 200, nil, nil)
|
|
var rateLimit *RateLimitError
|
|
if errLimit := limiter.observe("quota", 429, nil, nil); !errors.As(errLimit, &rateLimit) || !rateLimit.RetryAt.Equal(now.Add(time.Minute)) {
|
|
t.Fatalf("success did not reset backoff: %v", errLimit)
|
|
}
|
|
}
|
|
|
|
func TestGitHubRateLimitSuccessfulExhaustionAndLateResponses(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
|
|
headers := githubTestHeaders(map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(now.Add(time.Hour).Unix(), 10)})
|
|
if errObserve := limiter.observe("quota", 200, headers, nil); errObserve != nil {
|
|
t.Fatalf("final successful request failed: %v", errObserve)
|
|
}
|
|
_ = limiter.observe("quota", 200, nil, nil)
|
|
_ = limiter.observe("quota", 429, githubTestHeaders(map[string]string{"Retry-After": "60"}), nil)
|
|
var rateLimit *RateLimitError
|
|
if errCheck := limiter.check("quota"); !errors.As(errCheck, &rateLimit) || !rateLimit.RetryAt.Equal(now.Add(time.Hour)) {
|
|
t.Fatalf("late response shortened cooldown: %v", errCheck)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCooldownSharedAcrossClientsRepositoriesAndAssets(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
|
|
calls := 0
|
|
doer := pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "60"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
|
|
}
|
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`))}, nil
|
|
})
|
|
first := Client{HTTPClient: doer, RateLimiter: limiter}
|
|
second := Client{HTTPClient: doer, RateLimiter: limiter}
|
|
ctx := context.Background()
|
|
_, errFirst := first.FetchLatestRelease(ctx, Plugin{Repository: "https://github.com/owner/first"})
|
|
_, errSecond := second.FetchReleaseByTag(ctx, Plugin{Repository: "https://github.com/other/second"}, "v1.0.0")
|
|
_, errAsset := second.DownloadAsset(ctx, ReleaseAsset{APIURL: "https://api.github.com:443/repos/other/second/releases/assets/1"})
|
|
for _, errRequest := range []error{errFirst, errSecond, errAsset} {
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errRequest, &rateLimit) {
|
|
t.Fatalf("request error=%v, want rate limit", errRequest)
|
|
}
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("calls=%d, want 1", calls)
|
|
}
|
|
if _, errRaw := second.get(ctx, DefaultRegistryURL, "application/json", RequestKindRegistry, 0); errRaw != nil {
|
|
t.Fatalf("API cooldown blocked raw registry: %v", errRaw)
|
|
}
|
|
now = now.Add(time.Minute)
|
|
if _, errRecovered := second.FetchLatestRelease(ctx, Plugin{Repository: "https://github.com/other/second"}); errRecovered != nil || calls != 3 {
|
|
t.Fatalf("recovery error=%v calls=%d", errRecovered, calls)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCooldownSeparatesCredentialsAndNetworkScope(t *testing.T) {
|
|
t.Parallel()
|
|
limiter := &GitHubRateLimiter{}
|
|
calls := 0
|
|
client := Client{RateLimiter: limiter, HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "3600"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
|
|
})}
|
|
plugin := Plugin{Repository: "https://github.com/owner/repo"}
|
|
for _, token := range []string{"", "token-a", "token-b", "token-a", ""} {
|
|
client.ResolvedAuth = nil
|
|
if token != "" {
|
|
client.ResolvedAuth = []ResolvedAuthConfig{{Match: "https://api.github.com/", Type: AuthTypeGitHubToken, Token: Secret(token)}}
|
|
}
|
|
_, _ = client.FetchLatestRelease(context.Background(), plugin)
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("credential scopes made %d calls, want 3", calls)
|
|
}
|
|
client.NetworkScope = "other-egress"
|
|
_, _ = client.FetchLatestRelease(context.Background(), plugin)
|
|
if calls != 4 {
|
|
t.Fatalf("egress scopes made %d calls, want 4", calls)
|
|
}
|
|
}
|
|
|
|
func TestGitHubRateLimitAuthenticatedBodyIsNotExposed(t *testing.T) {
|
|
t.Parallel()
|
|
client := Client{
|
|
RateLimiter: &GitHubRateLimiter{},
|
|
ResolvedAuth: []ResolvedAuthConfig{{Match: "https://api.github.com/", Type: AuthTypeGitHubToken, Token: Secret("secret-token")}},
|
|
HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"message":"secondary rate limit: secret-token"}`))}, nil
|
|
}),
|
|
}
|
|
_, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errRelease, &rateLimit) || strings.Contains(errRelease.Error(), "secret-token") {
|
|
t.Fatalf("unsafe or unclassified error: %v", errRelease)
|
|
}
|
|
}
|
|
|
|
func TestGitHubDefaultCooldownBlocksConcurrentNewClients(t *testing.T) {
|
|
t.Parallel()
|
|
var calls atomic.Int32
|
|
client := Client{NetworkScope: t.Name(), HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
|
|
calls.Add(1)
|
|
return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "3600"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
|
|
})}
|
|
key := githubRateLimitKey("https://api.github.com/", client.NetworkScope, nil, false)
|
|
t.Cleanup(func() {
|
|
defaultGitHubRateLimiter.mu.Lock()
|
|
delete(defaultGitHubRateLimiter.entries, key)
|
|
defaultGitHubRateLimiter.mu.Unlock()
|
|
})
|
|
_, _ = client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/first"})
|
|
var wg sync.WaitGroup
|
|
for range 20 {
|
|
wg.Go(func() {
|
|
copyClient := Client{NetworkScope: client.NetworkScope, HTTPClient: client.HTTPClient}
|
|
_, errRelease := copyClient.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/other/second"})
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errRelease, &rateLimit) {
|
|
t.Errorf("request error = %v", errRelease)
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("calls=%d, want 1", calls.Load())
|
|
}
|
|
}
|
|
|
|
type githubHeaderOnlyBody struct {
|
|
reads int
|
|
closed bool
|
|
}
|
|
|
|
func (body *githubHeaderOnlyBody) Read([]byte) (int, error) {
|
|
body.reads++
|
|
return 0, errors.New("body must not be read")
|
|
}
|
|
|
|
func (body *githubHeaderOnlyBody) Close() error {
|
|
body.closed = true
|
|
return nil
|
|
}
|
|
|
|
func TestGitHubHeaderCooldownClosesBodyWithoutReading(t *testing.T) {
|
|
t.Parallel()
|
|
body := &githubHeaderOnlyBody{}
|
|
client := Client{RateLimiter: &GitHubRateLimiter{}, HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "60"}), Body: body}, nil
|
|
})}
|
|
_, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errRelease, &rateLimit) || body.reads != 0 || !body.closed {
|
|
t.Fatalf("error=%v reads=%d closed=%v", errRelease, body.reads, body.closed)
|
|
}
|
|
}
|
|
|
|
func TestGitHubSuccessfulFinalRequestReturnsRelease(t *testing.T) {
|
|
t.Parallel()
|
|
calls := 0
|
|
client := Client{RateLimiter: &GitHubRateLimiter{}, HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Header: githubTestHeaders(map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)}),
|
|
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`)),
|
|
}, nil
|
|
})}
|
|
release, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
|
|
if errRelease != nil || release.TagName != "v1.0.0" {
|
|
t.Fatalf("release=%#v error=%v", release, errRelease)
|
|
}
|
|
_, errNext := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/other"})
|
|
var rateLimit *RateLimitError
|
|
if !errors.As(errNext, &rateLimit) || calls != 1 {
|
|
t.Fatalf("next error=%v calls=%d", errNext, calls)
|
|
}
|
|
}
|
|
|
|
func TestNonGitHubRateLimitDoesNotCoolGitHub(t *testing.T) {
|
|
t.Parallel()
|
|
calls := 0
|
|
client := Client{RateLimiter: &GitHubRateLimiter{}, HTTPClient: pluginIdentityDoerFunc(func(req *http.Request) (*http.Response, error) {
|
|
calls++
|
|
if req.URL.Host != "api.github.com" {
|
|
return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "3600"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
|
|
}
|
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`))}, nil
|
|
})}
|
|
_, errRegistry := client.FetchRegistry(context.Background())
|
|
var rateLimit *RateLimitError
|
|
if errRegistry == nil || errors.As(errRegistry, &rateLimit) {
|
|
t.Fatalf("non-API error=%v, want ordinary HTTP error", errRegistry)
|
|
}
|
|
_, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
|
|
if errRelease != nil || calls != 2 {
|
|
t.Fatalf("release error=%v calls=%d", errRelease, calls)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitRetryAfterRoundsUp(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Unix(100, 0)
|
|
for _, tt := range []struct {
|
|
delay time.Duration
|
|
want int64
|
|
}{{-time.Second, 0}, {0, 0}, {time.Nanosecond, 1}, {time.Second, 1}, {time.Second + time.Nanosecond, 2}} {
|
|
errLimit := &RateLimitError{RetryAt: now.Add(tt.delay)}
|
|
if got := errLimit.RetryAfterSeconds(now); got != tt.want {
|
|
t.Fatalf("RetryAfterSeconds(%s)=%d, want %d", tt.delay, got, tt.want)
|
|
}
|
|
}
|
|
}
|