cd /news/developer-tools/the-github-api-rate-limit-broke-my-s… · home › topics › developer-tools › article
[ARTICLE · art-169441] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

The GitHub API rate limit broke my self-updater for 20 minutes: falling back to the releases/latest redirect without trusting it any more

A developer of the open-source Relaymote project built a fallback for its Windows self-updater after an unauthenticated GitHub API rate limit (60 requests/hour per IP) caused update checks to return HTTP 403 for about 20 minutes. The updater now falls back only on 403/429 errors to reading the Location header of the github.com/<owner>/<repo>/releases/latest redirect, discarding the body and constructing asset URLs from the fixed release-download pattern, while installer integrity still relies on SHA256SUMS and its signature rather than either version source.

by read4 min views4 publishedNov 14, 2026

Disclosure: I'm the developer of Relaymote, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.

Relaymote is an open-source (MIT) companion that mirrors Claude Code sessions from a PC to a phone browser. Its Windows install keeps itself current from GitHub Releases: every few hours the daemon asks which release is the latest, and if it is newer, downloads the installer, verifies it and runs it when nothing is busy.

"Asks which release is the latest" meant one call to api.github.com/repos/<owner>/<repo>/releases/latest. Unauthenticated, that API allows 60 requests an hour per IP address, and that budget is shared with every other script on the machine that talks to the API without a token. One evening another tool on the same PC polled it in a loop, spent the hour's budget, and for about twenty minutes the update check got HTTP 403 and did nothing.

The fix is small, and the interesting part is not the fallback itself but what it is allowed to change.

https://github.com/<owner>/<repo>/releases/latest is an ordinary web page, and it answers with a redirect to the newest release's tag page:

HTTP/1.1 302 Found
Location: https://github.com/<owner>/<repo>/releases/tag/v0.2.182

You do not need the body, only the Location header. So the fallback is one request that is never followed:

function latestFromPage(repo) {
  return new Promise((resolve, reject) => {
    const req = https.get(`https://github.com/${repo}/releases/latest`,
      { headers: { 'User-Agent': 'my-updater/' + current() }, timeout: 30000 }, res => {
        res.resume(); // discard the body; we only want the header
        const m = /\/releases\/tag\/v?([0-9][0-9A-Za-z.\-]*)$/.exec(String(res.headers.location || ''));
        if ([301, 302, 303, 307, 308].includes(res.statusCode) && m) return resolve(m[1]);
        reject(new Error(`no release redirect (HTTP ${res.statusCode})`));
      });
    req.on('timeout', () => req.destroy(new Error('timed out')));
    req.on('error', reject);
  });
}

Three details matter:

res.resume() The API response also listed each asset's download URL. Without it, the URLs have to be constructed, which works because release downloads follow a fixed pattern:

https://github.com/<owner>/<repo>/releases/download/v<version>/<file>

The updater already knew which file it wants for each kind of install, so it builds exactly three URLs: the installer, SHA256SUMS.txt and SHA256SUMS.txt.sig.

} catch (e) {
  out.error = e.message;
  if (/^HTTP (403|429)\b/.test(e.message)) {          // rate limited, nothing else
    try {
      const latest = await latestFromPage(s.repo);
      const base = `https://github.com/${s.repo}/releases/download/v${latest}/`;
      const names = [assetName(kind, latest), 'SHA256SUMS.txt', 'SHA256SUMS.txt.sig'].filter(Boolean);
      Object.assign(out, {
        ok: true, latest, newer: cmpVersion(latest, out.current) > 0,
        assets: Object.fromEntries(names.map(n => [n, base + n])),
        via: 'release page (API limited)',
      });
      delete out.error;
    } catch (e2) { out.error += '; release page: ' + e2.message; }
  }
}

Only 403 and 429 trigger it. A 404 (wrong repository), a DNS failure or a timeout still fail the check, because those are not "the API said no for now", and hiding them would make a broken configuration look healthy.

When both routes fail, the error keeps both reasons (HTTP 403; release page: no release redirect (HTTP 200)), so a log line explains the whole story.

The tempting worry is: the API is the "proper" source, and now the version comes from scraping a redirect. Is that less trustworthy?

It is not, because neither source was ever trusted with the installer's integrity. The download step does the same thing whichever route found the version:

SHA256SUMS.txt and SHA256SUMS.txt.sig.

if (!verifySums(sums, sig, key)) return { ok: false, error: 'SHA256SUMS.txt signature does not verify with the release key: refused' };
const want = listedHash(sums, name);
if (!want) return { ok: false, error: `${name} is not listed in the signed SHA256SUMS.txt: refused` };

So the worst a wrong version number can do is point at a release whose files fail those checks, and the update is refused. The redirect only answers "which version should I look at?", never "is this file safe to run?". That separation is what made the fallback a ten-line change rather than a security review.

If your updater trusts the API response for anything more than discovery (an asset URL from a third-party mirror, a hash from the same JSON), a fallback like this would be a downgrade. Put the trust in a signature first; then the discovery step can be as simple as you like.

Four checks were added, with https.get stubbed:

v9.9.9: the check succeeds, reports 9.9.9 as newer and records via: 'release page (API limited)'./releases/download/v9.9.9/, and the release page was actually requested. The code is lib/updater.js (check, latestFromPage, fetchVerified) and test/updater.js in the repository; the change shipped in 0.2.182.

── more in #developer-tools 4 stories · sorted by recency
── more on @relaymote 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-github-api-rate-…] indexed:0 read:4min 2026-11-14 · —