Skip to content

installer: name why GitHub's API refused, and retry a refused token once without it - #1890

Merged
AbirAbbas merged 4 commits into
devfrom
fix/installer-403-reason
Oct 22, 2026
Merged

AbirAbbas merged 4 commits into
devfrom
fix/installer-403-reason

Conversation

@AbirAbbas

Copy link
Copy Markdown
Collaborator

Fixes #1889.

What was wrong

When GitHub's API refused the release lookup, scripts/install.sh printed one sentence for every case: a spent rate limit, a rejected token, a forbidden repository, an outage and a dead network. http_get kept only the status code and discarded the headers and body that say which case it was. The advice or export GITHUB_TOKEN was backwards when an exported token was the cause. A token-bearing request that GitHub refused also aborted the install, even though the anonymous quota is a separate bucket.

What it does now

  • Every API response keeps its headers and body: curl via -D, and wget via -S plus --content-on-error where that wget offers the flag.
  • The reason is a pure function of (status, headers, body):
    • 401 → rejected token
    • 429, or a 403 with x-ratelimit-remaining: 0 or a rate limit body → rate limit
    • any other 403 → access
    • 404 → missing
    • 5xx → server
    • no status → unreachable
  • A rate-limit message says when the limit lifts. It reads x-ratelimit-reset and shows it in the host's local time. Without that header it falls back to Retry-After (for about N minutes), and with neither it says for up to an hour.
  • One anonymous retry: when GitHub refuses a token-bearing lookup with 401, 403 or 429, the token is set aside and the same request is made once without Authorization. If that works, the install goes on with one note: GitHub refused GITHUB_TOKEN (401: Bad credentials); continuing without it. If it fails, the message names both attempts.
  • No message tells a person who already exported a token to export one. The message names GH_TOKEN when that is the variable that supplied the token.
  • Unchanged: downloads never carry the token, there is no fallback to releases.atom or HTML, the website's CHANNEL/INSTALL_NAME seam is byte-identical, and stable's no stable build has been published yet stays when no token was set aside.
  • Bash 3.2-safe: no bash 4 constructs, and every [[ =~ ]] regex sits in a variable. The awk is POSIX-only (it runs under mawk and BusyBox awk), and date -d is never tried on BSD or Darwin.

Proof

Before and after, against real GitHub, with GITHUB_TOKEN=bogus and the public repository:

dev:    exit 1  codeaf: GitHub's API could not be reached or refused (a rate limit, or a repository you cannot read?); pin VERSION=<tag>, or export GITHUB_TOKEN
branch: exit 0  codeaf: GitHub refused GITHUB_TOKEN (401: Bad credentials); continuing without it
                ✓ Installed   codeaf v0.8.0

The issue's acceptance list, run through the stub at CODEAF_GITHUB_API (internal/release/install_test.go):

  • A token whose quota is spent, while anonymous requests still succeed: the install finishes and exits 0. The stub saw one token-bearing request, then one without Authorization, to the same path. This covers stable and --dev discovery, over curl and over wget.
  • A 401 with body Bad credentials: the message names the token as rejected, gives no export GITHUB_TOKEN advice, and exits 1.
  • A 403 with no rate-limit headers: the message names access, not quota.
  • A quota 403: the message shows the reset from x-ratelimit-reset in local time. The test runs with TZ=IST-5:30 and compares against the time Go computes.
  • Unit tests source the pure functions (marked BEGIN api refusal and END api refusal in the script) in a bare bash and check them case by case: reason, wait (including redirect header blocks, CRLF, header-name case, and oversized numbers) and message sanitising.
  • The existing tests that pinned the retired sentence are rewritten to the new contract, as the issue asks.

Gates: make pr-ready passed (light gate, tooling acceptance, and fresh internal/manual + internal/release).

A Codex review tried to break the change and found no blockers. Its two edge cases are fixed in the third commit: an oversized x-ratelimit-reset produced an invented time, and an oversized Retry-After gave negative minutes under BusyBox awk.

Limits

  • GNU wget discards a 401 body, so over wget a rejected token reads (401) without Bad credentials.
  • BusyBox wget discards error headers and bodies, so its quota note cannot name the reset time. The retry and the install still work.
  • The manual section says a message can lack these details.
  • Bash 3.2 compatibility was checked by construct and in bash's compatibility mode, not on a real macOS /bin/bash.

Manual

internal/manual/chat/running-from-the-terminal.md has a new section, "The installer stopped — GitHub API rate limit used up, 403, 401 Bad credentials, GITHUB_TOKEN refused…". It quotes the sentences above, and four new probes must reach it. The docs/GUIDE.md row for GITHUB_TOKEN/GH_TOKEN now mentions the anonymous retry.

🤖 Generated with Claude Code

AbirAbbas and others added 4 commits October 22, 2026 10:38
…nce without it

Every failed release lookup used to print one sentence for a spent rate limit,
a rejected token, a forbidden repository, an outage and a dead network, and it
advised exporting GITHUB_TOKEN even when the exported token was the cause.
http_get kept only the status, so the reason GitHub states on the response
(x-ratelimit-remaining, x-ratelimit-reset, the body's message) was thrown away.

The installer now keeps each API response's headers and body and names the
refusal: the rate limit with the local time it lifts, a rejected token, access,
a repository GitHub cannot find, a server fault, or an unreachable host. A token
GitHub refuses with 401, 403 or 429 is set aside and the same lookup is asked
once without it, because the anonymous quota is a separate bucket; when that
works the install goes on with a one-line note. Downloads still never carry the
token. The classifiers are pure functions between marker lines, and the tests
source that block directly.

Fixes #1889.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… lookup

The chat manual had no page for an installer that stopped on GitHub's API, and
the GUIDE said a token only raises the anonymous limit. running-from-the-terminal
now quotes the rate-limit, rejected-token and access sentences, the note printed
when a refused token is set aside, and the VERSION pin that skips the lookup,
with probes in a person's own words that must reach it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…inventing a wait

A 38-digit x-ratelimit-reset slipped past GNU date's refusal into bash's
printf fallback, which renders it as the epoch's first second, so the message
named a reset time GitHub never sent. A Retry-After that long overflowed
BusyBox awk into negative minutes. The reset now has at most eleven digits and
Retry-After at most six, and anything longer reads as no number, which says
"for up to an hour".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 9d3d7e8 into dev Oct 22, 2026
5 checks passed
@AbirAbbas
AbirAbbas deleted the fix/installer-403-reason branch October 22, 2026 15:58
AbirAbbas added a commit that referenced this pull request Oct 22, 2026
…nce without it (#1890)

Hotfix of #1890 onto v0.8.1 for the main install line. When GitHub's API
refused the release lookup, the installer printed one sentence for a spent
rate limit, a rejected token, refused access, an outage and a dead network,
and told people who had exported a token to export one. It now keeps each
API response's headers and body, names the refusal with the local time a
rate limit lifts, and, when GitHub refuses a token with 401, 403 or 429,
asks the same lookup once without it because the anonymous quota is a
separate bucket.

scripts/install.sh and internal/release/install_test.go are taken byte for
byte from #1890's head, so they also carry the installer half of #1832 that
staging has served since 2026-10-14: the installer no longer asks the
repository's former name after a 404, because that name is only GitHub's
redirect to the same repository. The manual, GUIDE and probe changes are
#1890's hunks alone.

Fixes #1889.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 22, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 22, 2026
History only: dev already holds #1890 (9d3d7e8) and #1832, so this merge
changes no file. It keeps staging and main ancestors of dev, so the next
promotion stays a fast-forward.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 23, 2026
v0.8.2 is v0.8.1 plus the installer hotfix #1890 (371d7f1), which also
carried #1832's installer half: the installer no longer asks the
repository's former name after a 404. #1832's own entry stays loose on dev
and staging for the release that ships the rest of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Oct 23, 2026
Adds the v0.8.2 section to CHANGELOG.md and removes #1890's entry, which
v0.8.2 released. Keeps staging and main ancestors of dev.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

installer: on a 403 from the GitHub API, name quota or access instead of one sentence, and retry anonymously

1 participant