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

> Source: <https://dev.to/ashwar_sadh_f7abe6f82c8e9/the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to-the-releaseslatest-42db>
> Published: 2026-11-14 06:14:44+00:00

*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](https://github.com/ashwarsadh/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:

``` js
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.
