Skip to content

Commit a537f0c

Browse files
xbtoshiclaude
andcommitted
v0.1.35: bypass api.github.com rate limit for update check
CheckLatest used api.github.com/repos/.../releases/latest, which unauthenticated is capped at 60 req/hour per IP. Users on shared NATs (Cloudflare WARP, carrier networks, offices) burn through that fast and the update check starts failing visibly. Switched to the HTML endpoint github.com/.../releases/latest, which 302-redirects to /releases/tag/<tag>. We disable redirect-following on the http.Client and pull the tag from the Location header — no API key, no JSON, no rate limit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9a1975e commit a537f0c

2 files changed

Lines changed: 52 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to **kyc-cli** and **sshwap** are documented here.
44
The format is loosely [Keep a Changelog](https://keepachangelog.com/);
55
versioning follows [SemVer](https://semver.org/).
66

7+
## [0.1.35] — 2026-05-11
8+
9+
### Fixed
10+
- **`kyc-cli update` no longer hits `api.github.com` (rate-limited).**
11+
Users behind shared NATs (Cloudflare WARP, mobile carriers, office
12+
networks) were hitting the 60-req/hour unauthenticated limit on
13+
`api.github.com/repos/.../releases/latest`. Switched to the HTML
14+
`github.com/.../releases/latest` endpoint, which 302-redirects to
15+
the latest tag and is not rate-limited the same way. The tag is
16+
parsed from the redirect Location header — no API key, no JSON.
17+
718
## [0.1.34] — 2026-05-11
819

920
### Fixed

internal/update/update.go

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"context"
2121
"crypto/sha256"
2222
"encoding/hex"
23-
"encoding/json"
2423
"errors"
2524
"fmt"
2625
"io"
@@ -34,49 +33,65 @@ import (
3433
)
3534

3635
const (
37-
repo = "kyc-rip/cli"
38-
releaseAPI = "https://api.github.com/repos/" + repo + "/releases/latest"
39-
checkCacheFile = "kyc-cli/lastcheck"
40-
checkInterval = 24 * time.Hour
36+
repo = "kyc-rip/cli"
37+
// releaseLatestURL is the HTML endpoint that 302-redirects to the
38+
// latest non-draft, non-prerelease tag. We use it instead of
39+
// api.github.com/.../releases/latest because the API endpoint is
40+
// rate-limited to 60 unauthenticated requests per IP per hour —
41+
// users behind shared NATs (Cloudflare WARP, mobile carriers,
42+
// office networks) blow through that quickly and the auto-update
43+
// nudge starts failing visibly.
44+
releaseLatestURL = "https://github.com/" + repo + "/releases/latest"
45+
checkCacheFile = "kyc-cli/lastcheck"
46+
checkInterval = 24 * time.Hour
4147
)
4248

43-
// LatestRelease is the slice of the GitHub releases API we use.
44-
type LatestRelease struct {
45-
TagName string `json:"tag_name"`
46-
HTMLURL string `json:"html_url"`
47-
Prerelease bool `json:"prerelease"`
48-
Draft bool `json:"draft"`
49-
}
50-
51-
// CheckLatest queries GitHub for the most recent (non-draft, non-pre)
52-
// release. Returns ("", nil) if there's nothing newer than `current`.
53-
// Returns the tag (e.g. "v0.1.6") if an upgrade is available.
49+
// CheckLatest resolves the latest non-draft, non-prerelease release tag
50+
// via the HTML redirect endpoint (no API rate limit). Returns ("", nil)
51+
// if `current` is already up to date.
5452
func CheckLatest(ctx context.Context, current string) (string, error) {
55-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseAPI, nil)
53+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseLatestURL, nil)
5654
if err != nil {
5755
return "", err
5856
}
59-
req.Header.Set("Accept", "application/vnd.github+json")
6057
req.Header.Set("User-Agent", "kyc-cli-self-update")
61-
resp, err := (&http.Client{Timeout: 4 * time.Second}).Do(req)
58+
// Disable automatic redirect following — we want the Location header
59+
// from the first 302 so we can read the tag without downloading the
60+
// full HTML release page (small, but pointless).
61+
client := &http.Client{
62+
Timeout: 4 * time.Second,
63+
CheckRedirect: func(*http.Request, []*http.Request) error {
64+
return http.ErrUseLastResponse
65+
},
66+
}
67+
resp, err := client.Do(req)
6268
if err != nil {
6369
return "", err
6470
}
6571
defer resp.Body.Close()
66-
if resp.StatusCode != 200 {
72+
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
6773
return "", fmt.Errorf("github releases: %s", resp.Status)
6874
}
69-
var rel LatestRelease
70-
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
71-
return "", err
75+
loc := resp.Header.Get("Location")
76+
if loc == "" {
77+
return "", errors.New("github releases: missing Location header")
78+
}
79+
// Location looks like ".../releases/tag/v0.1.34" — pull the trailing
80+
// path segment as the tag. If GitHub ever returns the bare
81+
// "/releases" path (no releases yet), `tag` will end up "releases"
82+
// and semverNewer will reject it.
83+
idx := strings.LastIndex(loc, "/")
84+
if idx < 0 || idx == len(loc)-1 {
85+
return "", fmt.Errorf("github releases: bad redirect %q", loc)
7286
}
73-
if rel.Draft || rel.Prerelease || rel.TagName == "" {
87+
tag := loc[idx+1:]
88+
if !strings.HasPrefix(tag, "v") {
7489
return "", nil
7590
}
76-
if !semverNewer(rel.TagName, current) {
91+
if !semverNewer(tag, current) {
7792
return "", nil
7893
}
79-
return rel.TagName, nil
94+
return tag, nil
8095
}
8196

8297
// CheckLatestThrottled is CheckLatest with a 24h-on-disk cache so we

0 commit comments

Comments
 (0)