paperclipai/paperclip · warning · ExternalChatWaitAuthorizationContentionError

ExternalChatWaitAuthorizationContentionError

Error message

ExternalChatWaitAuthorizationContentionError

What it means

Thrown by principalAuthorized when running in 'nonblocking' lock mode and the pg_try_advisory_xact_lock for the chat-identity key (companyId:principalId) cannot be acquired, meaning another transaction is concurrently authorizing or mutating the same external chat principal. The error (message 'paperclip_external_chat_wait_authorization_contended') signals lock contention rather than authorization failure; use isExternalChatWaitAuthorizationContention() to detect it (it also matches PG 55P03 lock_not_available).

Solutions

  1. Detect contention with isExternalChatWaitAuthorizationContention(err) and retry the authorization after a short backoff — contention is transient.
  2. Fall back to lockMode 'blocking' (pg_advisory_xact_lock) when the caller can afford to wait for the concurrent transaction to finish.
  3. Reduce lock hold time: keep transactions using this lock short and avoid slow I/O inside them.
  4. Serialize per-principal work (per-principal queue or deduplication) so concurrent requests for the same chat principal are rare.

Example fix

// before
const ok = await authorizeChatConversationForBoundRun(tx, endpoint, principalId, "nonblocking");

// after
let ok: boolean;
try {
  ok = await authorizeChatConversationForBoundRun(tx, endpoint, principalId, "nonblocking");
} catch (err) {
  if (!isExternalChatWaitAuthorizationContention(err)) throw err;
  ok = await authorizeChatConversationForBoundRun(tx, endpoint, principalId, "blocking"); // wait out the contention
}
Defensive patterns

Strategy: retry

Type guard

function isLockContention(err: unknown): boolean {
  return isExternalChatWaitAuthorizationContention(err);
}

Try / catch

for (let attempt = 0; attempt < 3; attempt++) {
  try {
    return await authorizeChatConversationForBoundRun(tx, endpoint, principalId, "nonblocking");
  } catch (err) {
    if (!isExternalChatWaitAuthorizationContention(err) || attempt === 2) throw err;
    await new Promise(r => setTimeout(r, 50 * 2 ** attempt));
  }
}

Prevention

When it happens

Trigger: authorizeChatConversationForBoundRun invoked with lockMode 'nonblocking' while a concurrent transaction already holds the advisory xact lock or row locks (FOR UPDATE NOWAIT) for the same endpoint company/principal — e.g. simultaneous webhook deliveries or a wait-authorization check racing a delivery-processing transaction.

Common situations: Duplicate/rapid-fire chat platform webhooks for the same user; a long-running delivery-processing transaction holding the lock; retry storms after a slow provider response; multiple worker instances processing the same principal concurrently.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/bd4560fa51f32202. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/native-runtime/chat-attachment-reuse.ts:338

  });
}

async function principalAuthorized(
  tx: Db,
  endpoint: typeof chatEndpoints.$inferSelect,
  principalId: string,
  lockMode: AuthorizationLockMode = "blocking",
): Promise<boolean> {
  if (lockMode === "blocking") {
    await tx.execute(
      sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0))`,
    );
  } else if (lockMode === "nonblocking") {
    const [lock] = (await tx.execute(
      sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as acquired`,
    )) as unknown as Array<{ acquired: boolean }>;
    if (!lock?.acquired) {
      throw new ExternalChatWaitAuthorizationContentionError();
    }
  }
  const principalQuery = tx
    .select({ id: chatExternalPrincipals.id })
    .from(chatExternalPrincipals)
    .where(
      and(
        eq(chatExternalPrincipals.id, principalId),
        eq(chatExternalPrincipals.companyId, endpoint.companyId),
        eq(chatExternalPrincipals.provider, endpoint.provider),
        eq(
          chatExternalPrincipals.providerAccountId,
          endpoint.providerAccountId ?? "",
        ),
      ),
    );
  const [principal] = await (lockMode === "read"
    ? principalQuery.limit(1)

View on GitHub (pinned to 3f1d897a7c)