Skip to content

Error response body reading consumes stream without cloning and silently swallows text() failures #4857

@AnushKamble

Description

@AnushKamble

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

  • res.text() failure is logged with error details
  • Body is cloned before throwIfRateLimited potentially consumes it
  • Existing error message fallback to '<empty>' is preserved
  • No breaking changes

Severity

Medium — error response bodies contain critical debugging info for 401/403/422 errors.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions