paperclipai/paperclip · warning
paperclip_runner_chat_attachment_read_busy
paperclip_runner_chat_attachment_read_busy
Error message
paperclip_runner_chat_attachment_read_busy: chat authorization is temporarily busy; retry this read shortly
What it means
Authorization of the external-chat wait/reuse happens inside a transaction with a 50ms lock_timeout; when authorization is contended by another writer (e.g. run-event persistence locking heartbeat_runs), the scope retries with short backoff for up to ~1 second. If the deadline expires while contention persists, it throws this busy error telling the caller to retry shortly.
Solutions
- Retry the read after a short delay — the error message explicitly says it is temporary.
- Reduce long-running transactions that lock heartbeat_runs or chat-lineage rows.
- Investigate lock contention (pg_locks / slow query logs) if this recurs.
- Lower the frequency of concurrent attachment reads per run.
Example fix
// before
const file = await scope.read(input); // may throw read_busy
// after
try {
const file = await scope.read(input);
} catch (e) {
if (String(e.message).startsWith("paperclip_runner_chat_attachment_read_busy"))
await new Promise(r => setTimeout(r, 250));
return scope.read(input);
throw e;
} Defensive patterns
Strategy: retry
Type guard
function isBusyError(e: unknown): boolean {
return e instanceof Error && e.message.startsWith("paperclip_runner_chat_attachment_read_busy");
} Try / catch
try {
return await scope.read(input);
} catch (e) {
if (isBusyError(e)) {
await new Promise(r => setTimeout(r, 500));
return scope.read(input); // single retry; error is explicitly transient
}
throw e;
} Prevention
- Keep transactions touching heartbeat_runs short to avoid lock contention.
- Schedule heavy event flushes away from interactive tool calls.
- Monitor pg_locks for recurring contention on run rows.
- Bound retries; the busy error already waits ~1s internally before surfacing.
When it happens
Trigger: Concurrent transactions holding locks the authorization needs for more than ~1s total; many simultaneous reads/writes against the same run rows during a busy window; lock convoy where run-event persistence repeatedly blocks authorizeOnce.
Common situations: High-concurrency boards where several runs and event writers contend on the same company/endpoint rows; slow analytics queries holding locks; batch event flushes coinciding with attachment reads.
Understand the failure class
Background: Request timed out: what client-side request timeouts mean across libraries (Request timed out, TIMED_OUT, APITimeoutError) — this error's family across 39 libraries.
Related errors
- connector_refresh_failed
- dropping batch after attempt(s); event(s) lost
- DUPLEX_CHANNEL_OPEN_FAILED
- ExternalChatWaitAuthorizationContentionError
- OpenCode event stream closed before the session became…
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/3b920d730cd22ffd.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/services/native-runtime/chat-attachment-read.ts:113
async #authorized(input: {
sourceCommentId: string;
attachmentId: string;
}): Promise<ChatAttachmentReuseSource> {
// Run-event persistence also briefly locks heartbeat_runs. A NOWAIT miss
// is not evidence of policy revocation: retry the whole authorization in
// a fresh transaction, never hold partial locks while backing off.
const deadline = Date.now() + 1_000;
for (;;) {
this.#assertOpen();
try {
return await this.#authorizeOnce(input);
} catch (error) {
this.#assertOpen();
if (!isExternalChatWaitAuthorizationContention(error)) throw error;
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new Error(
"paperclip_runner_chat_attachment_read_busy: chat authorization is temporarily busy; retry this read shortly",
);
}
await delay(Math.min(50, remaining), undefined, {
signal: this.#abort.signal,
}).catch(() => this.#assertOpen());
}
}
}
async #authorizeOnce(input: {
sourceCommentId: string;
attachmentId: string;
}): Promise<ChatAttachmentReuseSource> {
return this.options.db.transaction(async (transaction) => {
const tx = transaction as unknown as Db;
// Source rows are also locked by the existing lineage reader. Bound
// their waits so an inverse source-writer lock order cannot deadlock.View on GitHub (pinned to 3f1d897a7c)