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
- 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.
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
- 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.
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
- github_attachment_canonical_api_too_large
- github_attachment_empty
- github_attachment_invalid_response
- Anthropic Managed Agents request failed with HTTP ${response
- Anthropic did not return usable Agent and Environment identi
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/10c741c409395ac7.
Report an issue: GitHub.