paperclipai/paperclip · error · GitHubAttachmentUnavailableError

github_attachment_canonical_api_access_denied | github_attac

Error message

github_attachment_canonical_api_access_denied | github_attachment_canonical_api_status_unexpected | github_attachment_canonical_api_too_large | github_attachment_canonical_api_invalid_response

What it means

githubAttachmentCommentFetch validates the GitHub canonical API response before the body is consumed. When the HTTP status is 401/403/404, any non-200 status, a declared content-length over MAX_COMMENT_RESPONSE_BYTES, a missing body, or a non-JSON content-type, it cancels the body and throws GitHubAttachmentUnavailableError with the matching github_attachment_canonical_api_* code. It is thrown because the fetch is a strictly guarded, SSRF-safe retrieval of an exact GitHub comment, and anything but a normal 200 JSON response means the evidence cannot be trusted.

Source

Thrown at server/src/services/chat-github-attachments.ts:474

        connectTimeoutMs: 5000,
        responseTimeoutMs: DOWNLOAD_TIMEOUT_MS,
        error: () =>
          new GitHubAttachmentUnavailableError(
            "github_attachment_canonical_api_request_failed",
          ),
      },
    );
    if (
      response.status !== 200 ||
      !response.body ||
      !/^application\/json(?:;|$)/i.test(
        response.headers.get("content-type") ?? "",
      ) ||
      Number(response.headers.get("content-length") ?? 0) >
        MAX_COMMENT_RESPONSE_BYTES
    ) {
      await response.body?.cancel();
      throw new GitHubAttachmentUnavailableError(
        [401, 403, 404].includes(response.status)
          ? "github_attachment_canonical_api_access_denied"
          : response.status !== 200
            ? "github_attachment_canonical_api_status_unexpected"
            : Number(response.headers.get("content-length") ?? 0) >
                MAX_COMMENT_RESPONSE_BYTES
              ? "github_attachment_canonical_api_too_large"
              : "github_attachment_canonical_api_invalid_response",
      );
    }
    const reader = response.body.getReader();
    const chunks: Uint8Array[] = [];
    let size = 0;
    const cancel = () => {
      void reader.cancel().catch(() => undefined);
    };
    signal.addEventListener("abort", cancel, { once: true });
    try {

View on GitHub (pinned to 01ad858492)

Solutions

  1. Verify the GitHub credential used by the fetch can read the repo and comment (run `gh api <url>` with the same token); refresh/reinstall the token on 401/403.
  2. Confirm the comment still exists on GitHub; if it was deleted, re-attach the file from a live comment.
  3. Retry later on 5xx statuses — GitHub-side outages are the usual cause of status_unexpected.
  4. Check that the request is not being redirected or intercepted by a proxy/gateway that returns HTML (invalid_response) since redirect: manual is set and non-JSON bodies are rejected.

Example fix

// before: assuming any response is usable JSON
const data = await fetch(url).then(r => r.json());
// after: check status and content-type before parsing
const res = await fetch(url);
if (res.status === 404) throw new Error('comment deleted');
if (!res.headers.get('content-type')?.startsWith('application/json')) throw new Error('non-JSON response');
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-check with the same token before the guarded fetch
const probe = await fetch(canonicalUrl, { headers: { accept: 'application/json' } });
if ([401,403,404].includes(probe.status)) throw new Error('no access to comment');
if (!probe.headers.get('content-type')?.startsWith('application/json')) throw new Error('non-JSON response');
if (Number(probe.headers.get('content-length') ?? 0) > MAX_COMMENT_RESPONSE_BYTES) throw new Error('too large');

Type guard

function isUsableCanonicalResponse(r: Response): boolean {
  return r.status === 200 &&
    !!r.body &&
    /^application\/json(?:;|$)/i.test(r.headers.get('content-type') ?? '') &&
    Number(r.headers.get('content-length') ?? 0) <= MAX_COMMENT_RESPONSE_BYTES;
}

Try / catch

try {
  const target = resolveGitHubCommentAttachmentTarget(attachment, value);
  if (!target) {
    // GitHubAttachmentUnavailableError was mapped to null; fall back to a stored signed copy
    return renderFallback(attachment);
  }
} catch (e) {
  if (e instanceof GitHubAttachmentUnavailableError &&
      /canonical_api_(access_denied|status_unexpected|too_large|invalid_response)/.test(e.code)) {
    log.warn('canonical GitHub comment fetch rejected', { code: e.code });
    return renderFallback(attachment); // retry later or ask user to re-attach
  }
  throw e;
}

Prevention

When it happens

Trigger: The GET to the canonical api.github.com issue/comment URL returns 401, 403, or 404 (access denied); any other non-200 status (e.g. 500, 502, 451); a declared Content-Length header exceeding MAX_COMMENT_RESPONSE_BYTES; a response with no body; or a Content-Type that is not application/json (e.g. text/html rate-limit page).

Common situations: GitHub token expired or lacks access to a private repo (401/403); the comment or issue was deleted (404); GitHub is returning 5xx or an HTML abuse/rate-limit page with a non-JSON content-type; a proxy intercepts and returns HTML.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/10c741c409395ac7. Report an issue: GitHub.