A 202 Accepted Is Not Zero Contributors

One number on my GitHub profile card kept lying with excellent typography.
Lines of code changed sometimes dropped to zero or came out suspiciously small.
The failure mode was quiet: unavailable data could become a numeric zero inside an otherwise complete-looking SVG.
Nothing in the card's shape explained why the number should not be trusted.
The culprit was an HTTP response that sounded much more successful than it was:
202 Accepted
For GitHub repository statistics, 202 does not mean “the contributor count is zero.”
It means the statistics are not cached yet, so GitHub has started a background job and expects the client to try again after a short wait.
That behavior is documented in GitHub's official repository-statistics REST documentation.
The old failure mode collapsed three different states into one number:
real zero
not ready yet
could not compute
All three could become 0 on the public card.
That is not graceful degradation. It is uncertainty wearing a number costume.
Retry is correct, but retry is not a recovery strategy
The first response to 202 should be boring: wait briefly and ask again.
My query layer accepts a set of retryable statuses, makes up to ten attempts, and adds a random delay between zero and four seconds:
if response_status in retry_statuses and attempt + 1 < max_attempts:
delay = random.uniform(0, 4)
await asyncio.sleep(delay)
continue
The contributor path calls it with:
status, response = await query_rest_response(
f"/repos/{repo}/stats/contributors",
max_attempts=10,
retry_statuses={202, 403, 429},
verbose=False,
)
Why the random delay?
This job processes multiple repositories concurrently. Without jitter, several requests that receive the same temporary response can wake up together and knock on the same door again in formation.
Jitter spreads those retries across a small window. It does not guarantee success. It simply avoids coordinating the retry storm quite so neatly. (Distributed systems are very good at organizing the wrong meeting.)
After ten attempts, the client still needs an answer to a harder question:
If the derived API value never becomes available, can I reconstruct the metric from a more primary source?
For lines changed, the answer is yes. Git history already contains additions, deletions, and commit authorship.
The fallback crosses from an API projection to repository history
The fallback is not another statistics endpoint. It changes evidence sources.
The pipeline becomes:
GitHub contributor-stats API
→ short retries
→ lightweight bare clone
→ git log --numstat
→ author-header regex filtering
→ additions and deletions
The clone deliberately avoids a normal working tree:
git clone \
--bare \
--filter=blob:limit=1m \
--no-tags \
--single-branch \
<repo-url> \
<temporary-path>
Then the tool runs:
git -C <temporary-path> log --numstat --pretty=tformat:
Each --numstat line provides additions and deletions for a changed path.
Binary or otherwise non-numeric fields are ignored rather than guessed.
The temporary bare repository is deleted when the calculation finishes.
This is slower than reading a cached API response. That is why it is a fallback rather than the default.
It is also a better failure mode than publishing a synthetic zero.
Counting lines requires an explicit identity approximation
Cloning the repository solves availability. It creates a new correctness problem.
Whose commits should be counted?
The contributor-stats API returns entries keyed by GitHub login.
git log sees author names and email addresses stored in commits.
Those identity systems are related, but they are not interchangeable.
My fallback queries:
/user/emails
It then adds one --author filter for each known email string:
for email in emails:
log_command.extend(["--author", email])
Multiple patterns broaden the set beyond a single noreply address.
But this is not an exact email-field comparison.
Git's --author documentation says the argument is a regular expression matched against the whole author header, and multiple --author patterns are combined as alternatives.
This implementation does not escape or anchor the address strings and does not add --fixed-strings.
An email containing regex-significant characters can therefore describe a broader pattern than its literal value.
The regression test makes the implemented boundary concrete without proving exact identity.
It supplies two email strings, verifies that both become --author arguments, feeds git log --numstat output through the parser, and checks the combined additions and deletions.
The fallback is not searching for a display name such as Andrew.
Display names are neither unique nor stable enough to carry this metric.
Email-derived author patterns are more specific, but this unescaped regex form remains an approximation that can overmatch.
The last-resort identity is deliberately weaker
The email API may be unavailable because the token lacks permission.
In that case, the tool falls back to:
f"{login}@users.noreply.github.com"
This keeps the recovery pipeline moving, but it reduces the identity set from “all returned contributor emails” to one inferred address.
The limitation is explicit: commits authored with another email may be missed.
That means the noreply path can undercount.
It should not be described as equivalent to a successful /user/emails response.
This distinction matters because fallback chains often become progressively less precise:
primary API data → convenient and attributed by login
git + full email list → reconstructable and broader identity coverage
git + noreply address → reconstructable but potentially incomplete
no API and no git → failed, contributes zero with a failure label
The pipeline improves resilience without pretending every stage offers the same guarantee.
A real zero is different from a failed repository
The code treats 204 No Content as a successful API result with zero additions and deletions:
if status == 204:
return 0, 0, "api"
A non-recoverable API error is different:
return 0, 0, "other_api_error"
Both paths contribute the same numeric pair to the total. They do not contribute the same provenance.
That third return value—api, git_fallback, or a failure reason—is what stops the aggregate from losing the difference.
The public metric can still use a numeric total. The workflow can separately report how that total was assembled.
This pattern is useful beyond coding statistics.
Whenever a batch calculation substitutes a neutral numeric value after failure, carry source state alongside the value:
value, source = compute()
or, when failure categories matter:
value, provenance, limitation = compute()
The data structure should make it difficult to confuse “measured zero” with “zero inserted because measurement failed.”
Test the exhausted path, not only the happy retry
A retry test that returns 200 on the second request proves only that retrying can work.
The more important regression test feeds the contributor endpoint ten 202 responses:
responses={
"/repos/owner/repo/stats/contributors": [(202, {})] * 10,
}
The test replaces the actual clone with a mock fallback result and verifies two things:
self.assertEqual(result, (7, 3))
stats._get_lines_changed_from_git.assert_awaited_once_with("owner/repo")
This fixture reaches the branch the production incident cared about: the API never becomes ready within the attempt budget.
Separate tests cover:
- multiple contributor-email patterns in the
git logcommand - noreply fallback when the email list is unavailable
- missing
giton the runner - one API success, one git fallback, and one failed repository in the same batch
- aggregate failure-cause text from preclassified counters
At the checked revision, the focused suite runs sixteen tests without network access. The API and subprocess boundaries are replaced with deterministic fakes, while response interpretation, provenance, and aggregation logic stays real.
That is the right level of test for this contract. A live GitHub API test would add rate limits and cache timing to the very behavior we are trying to make deterministic.
A fallback should preserve the question, not just produce a value
The original question was:
How many lines did this authenticated contributor add and delete across the selected repositories?
The contributor-stats API answers that question using GitHub's cached aggregation.
The git fallback approximates it by applying email-derived regular expressions to Git author headers and summing the resulting --numstat entries.
Those implementations are different, and the fallback's regex semantics weaken the identity guarantee even though it is still trying to preserve the same question.
A bad fallback would produce any available repository-wide number just to avoid an empty result. That might make the card look healthier while silently changing “this contributor's lines” into “everyone's lines.”
Resilience is not the ability to return something. It is the ability to return an answer with a traceable relationship to the original contract.
The 202 response taught me to stop treating HTTP status families as final business values.
2xx can still mean “not ready.”
0 can still mean “failed.”
And a polished dashboard can still be wrong while every pixel is exactly where it belongs.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.