{"slug": "the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to", "title": "The GitHub API rate limit broke my self-updater for 20 minutes: falling back to the releases/latest redirect without trusting it any more", "summary": "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.", "body_md": "*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.*\n\n[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.\n\n\"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.\n\nThe fix is small, and the interesting part is not the fallback itself but what it is allowed to change.\n\n`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:\n\n```\nHTTP/1.1 302 Found\nLocation: https://github.com/<owner>/<repo>/releases/tag/v0.2.182\n```\n\nYou do not need the body, only the `Location` header. So the fallback is one request that is never followed:\n\n``` js\nfunction latestFromPage(repo) {\n  return new Promise((resolve, reject) => {\n    const req = https.get(`https://github.com/${repo}/releases/latest`,\n      { headers: { 'User-Agent': 'my-updater/' + current() }, timeout: 30000 }, res => {\n        res.resume(); // discard the body; we only want the header\n        const m = /\\/releases\\/tag\\/v?([0-9][0-9A-Za-z.\\-]*)$/.exec(String(res.headers.location || ''));\n        if ([301, 302, 303, 307, 308].includes(res.statusCode) && m) return resolve(m[1]);\n        reject(new Error(`no release redirect (HTTP ${res.statusCode})`));\n      });\n    req.on('timeout', () => req.destroy(new Error('timed out')));\n    req.on('error', reject);\n  });\n}\n```\n\nThree details matter:\n\n`res.resume()`\nThe 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:\n\n```\nhttps://github.com/<owner>/<repo>/releases/download/v<version>/<file>\n```\n\nThe 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`.\n\n```\n} catch (e) {\n  out.error = e.message;\n  if (/^HTTP (403|429)\\b/.test(e.message)) {          // rate limited, nothing else\n    try {\n      const latest = await latestFromPage(s.repo);\n      const base = `https://github.com/${s.repo}/releases/download/v${latest}/`;\n      const names = [assetName(kind, latest), 'SHA256SUMS.txt', 'SHA256SUMS.txt.sig'].filter(Boolean);\n      Object.assign(out, {\n        ok: true, latest, newer: cmpVersion(latest, out.current) > 0,\n        assets: Object.fromEntries(names.map(n => [n, base + n])),\n        via: 'release page (API limited)',\n      });\n      delete out.error;\n    } catch (e2) { out.error += '; release page: ' + e2.message; }\n  }\n}\n```\n\nOnly `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.\n\nWhen 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.\n\nThe tempting worry is: the API is the \"proper\" source, and now the version comes from scraping a redirect. Is that less trustworthy?\n\nIt 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:\n\n`SHA256SUMS.txt` and `SHA256SUMS.txt.sig`.\n\n```\nif (!verifySums(sums, sig, key)) return { ok: false, error: 'SHA256SUMS.txt signature does not verify with the release key: refused' };\nconst want = listedHash(sums, name);\nif (!want) return { ok: false, error: `${name} is not listed in the signed SHA256SUMS.txt: refused` };\n```\n\nSo 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.\n\nIf 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.\n\nFour checks were added, with `https.get` stubbed:\n\n`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.\nThe code is `lib/updater.js` (`check`, `latestFromPage`, `fetchVerified`) and `test/updater.js` in the repository; the change shipped in 0.2.182.", "url": "https://wpnews.pro/news/the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to", "canonical_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_at": "2026-11-14 06:14:44+00:00", "updated_at": "2026-11-14 06:21:07.362799+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Relaymote", "GitHub", "Claude Code", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to", "markdown": "https://wpnews.pro/news/the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to.md", "text": "https://wpnews.pro/news/the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to.txt", "jsonld": "https://wpnews.pro/news/the-github-api-rate-limit-broke-my-self-updater-for-20-minutes-falling-back-to.jsonld"}}