Description
In lib/github.ts:670-688, the fetchGitHubContributions function attempts to read the error response body for diagnostic messages. If res.text() rejects (e.g., network interruption during body streaming, premature connection close), the error is silently swallowed by .catch(() => ''):
const bodyText = await res.text().catch(() => '');
Impact
When res.text() fails, the error message shows <empty> instead of the response body:
throw new Error(
`GitHub GraphQL API returned status ${res.status} after ${MAX_RETRIES} retries. Response: ${bodyText || '<empty>'}`
);
This is particularly harmful for 4xx errors where the response body contains the failure reason:
- 401 Unauthorized: GitHub typically returns a body explaining why the token is invalid (expired, wrong format, insufficient scopes)
- 403 Forbidden: Body contains rate-limit reset timestamps or abuse detection details
- 422 Unprocessable: Body includes GraphQL validation error details
Without the body text, debugging these errors requires reproducing the exact failure, which is time-consuming in production.
Current Code (lines 677-688)
if (!res.ok) {
throwIfRateLimited(res);
const bodyText = await res.text().catch(() => '');
if (res.status === 401) {
throw new Error(`GitHub PAT is invalid or missing. Response: ${bodyText || '<empty>'}`);
}
throw new Error(
`GitHub GraphQL API returned status ${res.status} after ${MAX_RETRIES} retries. Response: ${bodyText || '<empty>'}`
);
}
Note
res.text() is called after throwIfRateLimited(res), which also consumes the response body in some cases. This may cause res.text() to fail with a "body already consumed" error. The catch(() => '') hides this — but the root cause is that throwIfRateLimited should be checked without consuming the body, or res.clone() should be used.
Suggested Fix
if (!res.ok) {
const bodyText = await res.clone().text().catch((err) => {
console.warn('[GitHub API] Failed to read error response body:', err?.message ?? err);
return '';
});
throwIfRateLimited(res);
if (res.status === 401) {
throw new Error(`GitHub PAT is invalid or missing. Response: ${bodyText || '<empty>'}`);
}
throw new Error(
`GitHub GraphQL API returned status ${res.status} after ${MAX_RETRIES} retries. Response: ${bodyText || '<empty>'}`
);
}
Additional Cases with the Same Pattern
lib/github.ts:272-275 — .catch(() => null) in fetchGraphQLWithRetry rate-limit body parsing
lib/github.ts:970 — .catch(() => null) in team contributions (loss of member error visibility)
Acceptance Criteria
Severity
Medium — error response bodies contain critical debugging info for 401/403/422 errors.
Description
In
lib/github.ts:670-688, thefetchGitHubContributionsfunction attempts to read the error response body for diagnostic messages. Ifres.text()rejects (e.g., network interruption during body streaming, premature connection close), the error is silently swallowed by.catch(() => ''):Impact
When
res.text()fails, the error message shows<empty>instead of the response body:This is particularly harmful for 4xx errors where the response body contains the failure reason:
Without the body text, debugging these errors requires reproducing the exact failure, which is time-consuming in production.
Current Code (lines 677-688)
Note
res.text()is called afterthrowIfRateLimited(res), which also consumes the response body in some cases. This may causeres.text()to fail with a "body already consumed" error. Thecatch(() => '')hides this — but the root cause is thatthrowIfRateLimitedshould be checked without consuming the body, orres.clone()should be used.Suggested Fix
Additional Cases with the Same Pattern
lib/github.ts:272-275—.catch(() => null)infetchGraphQLWithRetryrate-limit body parsinglib/github.ts:970—.catch(() => null)in team contributions (loss of member error visibility)Acceptance Criteria
res.text()failure is logged with error detailsthrowIfRateLimitedpotentially consumes it'<empty>'is preservedSeverity
Medium — error response bodies contain critical debugging info for 401/403/422 errors.