Skip to content

fix(github): name the kickstart failure instead of returning a bare 500 - #328

Open
Lob26 wants to merge 6 commits into
theam:mainfrom
Lob26:fix/kickstart-diagnosable-failures
Open

Lob26 wants to merge 6 commits into
theam:mainfrom
Lob26:fix/kickstart-diagnosable-failures

Conversation

@Lob26

@Lob26 Lob26 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #211.

The report, and what actually happens

#211 reaches kickstart with working GitHub authentication, selects a repository, and gets Internal server error. There is nothing else to go on — not whether the repository, the installation, or Facility is at fault. That is the first flow a new installation runs, so it is the worst possible place for an opaque answer.

There are two causes behind the one symptom.

1. Octokit reports status; the error handler reads statusCode.

// services/api/src/app.ts
const status = typeof err.statusCode === "number" ? err.statusCode : 500;
if (status >= 500) { /* logged, masked as "Internal server error" */ }

@octokit/request-error sets status, not statusCode. So every refusal GitHub can give — 404, 403, 409, 429 — falls into the 500 branch and is masked. The codebase already knows this in one place; kickstartRepo reads (error as { status?: number }).status when handling a 422 from createBranch, and nowhere else.

For the reported case the failing call is getDefaultBranchSha() → repos.getBranch, which answers 404 on a repository with no commits. facility-test reads like a freshly created repository, which fits: readRepoFiles swallows every getContent failure, so preview renders happily, and the first call that actually needs a base commit is the one that dies.

2. Kickstart's own refusals were bare Errors, which have no statusCode at all — including "Repository already contains the Facility 0.12 kickstart files". That is a conflict the caller can act on, delivered as a server fault.

The change

Map both, in the one boundary that owns the GitHub call.

Condition Before After
Base ref unreadable (no commits, missing branch, repo no longer in the installation) 500 404 kickstart_repository_unreachable
GitHub reports the repository empty 500 409 kickstart_repository_empty
Installation refuses the request 500 403 kickstart_repository_forbidden
Installation rate limited 500 429 kickstart_github_rate_limited
Repository has no installation 500 409 github_installation_missing
Installation suspended or foreign 500 409 github_installation_unavailable
Kickstart files already present 500 409 kickstart_already_applied

Each message names the repository, the ref, and the remedy. Statuses with no advice attached are passed through untouched, so a genuine upstream fault is still logged and masked by sendError — the fix narrows what gets a 500, it does not stop masking.

Test

New services/api/test/kickstart-failures.test.ts, six cases against a fake Octokit that throws status-carrying errors the way @octokit/request-error does — no daemon, no network, runs in the default suite. Five fail on unmodified main; the sixth is the regression guard that an unmapped status stays masked, and it passes both ways by design.

Found but not fixed

readRepoFiles swallows every getContent error, so kickstart preview succeeds on a repository that apply cannot use. #211's reporter gets all the way through "preview the assets" before the failure appears. Making preview resolve the base ref would surface the same diagnosable error one step earlier, which reads like the right behaviour and matches #280's "invalid contracts fail before any partial setup command runs". I left it out to keep this to one change; happy to add it here or open it separately, whichever you prefer.

Distinguishing "empty repository" from "branch does not exist" would need one extra repos.get call on the error path. I did not add it — the 404 message names both possibilities rather than inventing a diagnosis.

Verification

Windows 11, Node 26, compose Postgres.

I could not reproduce against a live GitHub App, so the status-to-condition mapping is from the REST documentation and Octokit's error shape rather than from observed responses. The fake reproduces the shape; a real empty-repository run would confirm the 404 path end to end.

Claude Code helped

Kickstart answered "Internal server error" for every refusal GitHub could
give it, which is the least useful answer possible for the first flow a
new installation runs: the operator cannot tell whether the repository,
the installation, or Facility is at fault.

Two causes, one symptom. Octokit reports HTTP failures on `status`, while
the API error handler reads `statusCode`, so a 404, 403 or 409 from GitHub
fell through to the generic 500 branch. And kickstart's own refusals were
bare `Error`s, including "already contains the Facility 0.12 kickstart
files" — a conflict the caller can act on, delivered as a server fault.

Map both. An unreadable base ref, an empty repository, a refused or
throttled installation, a missing or suspended connection, and an
already-kickstarted repository now answer 4xx with a stable code and a
message naming the repository, the ref and the remedy. Statuses with no
advice attached stay unmapped, so genuinely unknown failures are still
logged and masked.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this.

Please distinguish 403 rate limits from permission failures using the existing helper. Also avoid treating every 409 as an empty repository—a later ref conflict gets that message too. Add regression coverage for both.

…m an empty repo

Two statuses were carrying two conditions each, so the message named the
wrong remedy half the time.

GitHub answers 403 both when an installation may not do something and
when it has spent its budget. Calling every 403 a permission fault sends
an operator to the App settings while GitHub is only asking them to wait.
`githubRateLimitRetryAt` already makes that decision from the headers for
the mirror and trigger paths, so kickstart now asks it first and reports
the reset instant instead of advice about permissions. It answers for
every 429 as well, so that branch is gone.

GitHub also answers 409 for a repository with no commits and for a ref
that stopped being a fast-forward. Only the first is fixed by pushing.
`applyKickstart` now separates resolving the base commit from writing the
refs, and the segment that failed picks the message: a 409 while resolving
the base is an empty repository, a 409 once the branch is being written is
another writer having moved it.

The failure mapper is no longer wrapped around `readRepoFiles`, which
swallows every per-path failure and cannot surface one. It is wrapped
around installation-token minting instead, which can.

Three regressions, all red without the change: a 403 carrying
`x-ratelimit-remaining: 0` reports the reset rather than a permission
fault, a plain 403 still reports the permission fault, and a non
fast-forward ref update reports a moving branch rather than a repository
without commits.
@Lob26

Lob26 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at eee45b1. Both were one status carrying two conditions, and my message named the wrong remedy half the time. Kickstart now asks githubRateLimitRetryAt first, so a throttled 403 reports the reset instead of sending someone to the App permissions. For 409 I split applyKickstart into resolving the base and writing the refs: a 409 while resolving is an empty repository, a 409 once the branch is being written is another writer having moved it. Three regressions, all red without the change. Thanks for the pointer to the helper.

This branch has not been deployed

No deployments
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.

Kickstart returns "Internal server error" when processing a repository

2 participants