{"record":{"id":"10c741c409395ac7","repo":"paperclipai/paperclip","slug":"github-attachment-canonical-api-access-denied-gi","errorCode":null,"errorMessage":"github_attachment_canonical_api_access_denied | github_attachment_canonical_api_status_unexpected | github_attachment_canonical_api_too_large | github_attachment_canonical_api_invalid_response","messagePattern":"github_attachment_canonical_api_access_denied \\| github_attachment_canonical_api_status_unexpected \\| github_attachment_canonical_api_too_large \\| github_attachment_canonical_api_invalid_response","errorType":"error_code","errorClass":"GitHubAttachmentUnavailableError","httpStatus":null,"severity":"error","filePath":"server/src/services/chat-github-attachments.ts","lineNumber":474,"sourceCode":"        connectTimeoutMs: 5000,\n        responseTimeoutMs: DOWNLOAD_TIMEOUT_MS,\n        error: () =>\n          new GitHubAttachmentUnavailableError(\n            \"github_attachment_canonical_api_request_failed\",\n          ),\n      },\n    );\n    if (\n      response.status !== 200 ||\n      !response.body ||\n      !/^application\\/json(?:;|$)/i.test(\n        response.headers.get(\"content-type\") ?? \"\",\n      ) ||\n      Number(response.headers.get(\"content-length\") ?? 0) >\n        MAX_COMMENT_RESPONSE_BYTES\n    ) {\n      await response.body?.cancel();\n      throw new GitHubAttachmentUnavailableError(\n        [401, 403, 404].includes(response.status)\n          ? \"github_attachment_canonical_api_access_denied\"\n          : response.status !== 200\n            ? \"github_attachment_canonical_api_status_unexpected\"\n            : Number(response.headers.get(\"content-length\") ?? 0) >\n                MAX_COMMENT_RESPONSE_BYTES\n              ? \"github_attachment_canonical_api_too_large\"\n              : \"github_attachment_canonical_api_invalid_response\",\n      );\n    }\n    const reader = response.body.getReader();\n    const chunks: Uint8Array[] = [];\n    let size = 0;\n    const cancel = () => {\n      void reader.cancel().catch(() => undefined);\n    };\n    signal.addEventListener(\"abort\", cancel, { once: true });\n    try {","sourceCodeStart":456,"sourceCodeEnd":492,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/chat-github-attachments.ts#L456-L492","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["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.","Confirm the comment still exists on GitHub; if it was deleted, re-attach the file from a live comment.","Retry later on 5xx statuses — GitHub-side outages are the usual cause of status_unexpected.","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."],"exampleFix":"// before: assuming any response is usable JSON\nconst data = await fetch(url).then(r => r.json());\n// after: check status and content-type before parsing\nconst res = await fetch(url);\nif (res.status === 404) throw new Error('comment deleted');\nif (!res.headers.get('content-type')?.startsWith('application/json')) throw new Error('non-JSON response');","handlingStrategy":"try-catch","validationCode":"// Pre-check with the same token before the guarded fetch\nconst probe = await fetch(canonicalUrl, { headers: { accept: 'application/json' } });\nif ([401,403,404].includes(probe.status)) throw new Error('no access to comment');\nif (!probe.headers.get('content-type')?.startsWith('application/json')) throw new Error('non-JSON response');\nif (Number(probe.headers.get('content-length') ?? 0) > MAX_COMMENT_RESPONSE_BYTES) throw new Error('too large');","typeGuard":"function isUsableCanonicalResponse(r: Response): boolean {\n  return r.status === 200 &&\n    !!r.body &&\n    /^application\\/json(?:;|$)/i.test(r.headers.get('content-type') ?? '') &&\n    Number(r.headers.get('content-length') ?? 0) <= MAX_COMMENT_RESPONSE_BYTES;\n}","tryCatchPattern":"try {\n  const target = resolveGitHubCommentAttachmentTarget(attachment, value);\n  if (!target) {\n    // GitHubAttachmentUnavailableError was mapped to null; fall back to a stored signed copy\n    return renderFallback(attachment);\n  }\n} catch (e) {\n  if (e instanceof GitHubAttachmentUnavailableError &&\n      /canonical_api_(access_denied|status_unexpected|too_large|invalid_response)/.test(e.code)) {\n    log.warn('canonical GitHub comment fetch rejected', { code: e.code });\n    return renderFallback(attachment); // retry later or ask user to re-attach\n  }\n  throw e;\n}","preventionTips":["Keep the GitHub token for canonical fetches fresh and scoped to read the relevant repos.","Before resolving, confirm the comment still exists with a lightweight HEAD/GET probe.","Never assume 200: always branch on status and validate content-type before parsing.","Watch for proxies that rewrite responses to HTML (SSO walls, abuse pages) and allowlist api.github.com."],"tags":["github","http","api-response","access-denied"],"backgroundTag":"http-error-response","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}