···153153 }
154154}
155155156156+/**
157157+ * True when restoring an OAuth session failed because the session no longer
158158+ * exists or can't be refreshed: the user revoked the app on their PDS, the
159159+ * refresh token expired, or the session row was never written / already
160160+ * deleted. The library signals all of these by throwing `TokenRefreshError` /
161161+ * `TokenRevokedError`; we also match the message for resilience across library
162162+ * versions. Callers treat this as a benign drop rather than a retryable error,
163163+ * so per-DID work doesn't loop forever once a user disconnects.
164164+ */
165165+export function isSessionGone(err: unknown): boolean {
166166+ if (!err || typeof err !== 'object') return false
167167+ const name = 'name' in err && typeof err.name === 'string' ? err.name : ''
168168+ if (name === 'TokenRefreshError' || name === 'TokenRevokedError') return true
169169+ const message = 'message' in err && typeof err.message === 'string' ? err.message : ''
170170+ return /session was deleted|token (has been )?revoked|no refresh token/i.test(message)
171171+}
172172+173173+/**
174174+ * Restore an OAuth session for `did`, or null when the session is gone (see
175175+ * `isSessionGone`). Any other failure re-throws so genuine/transient errors
176176+ * still surface to the queue's retry.
177177+ */
178178+export async function restoreSessionOrNull(did: string) {
179179+ const client = await useOAuthClient()
180180+ try {
181181+ return await client.restore(did)
182182+ }
183183+ catch (err) {
184184+ if (isSessionGone(err)) return null
185185+ throw err
186186+ }
187187+}
188188+156189/** Test hook: drop the cached client. */
157190export function clearOAuthClientCache() {
158191 cachedClient = undefined
···11import type { OAuthSession } from '@atproto/oauth-client-node'
22import { and, eq, sql } from 'drizzle-orm'
33import { installation, repoMapping, userIdentity } from '../db/schema'
44-import { useOAuthClient } from './atproto-oauth'
44+import { restoreSessionOrNull } from './atproto-oauth'
55import { useDb } from './db'
66import { installationOctokit } from './github-app'
77import type { JobEnvelope } from './queue'
···203203 // fail forever on the foreign key. Drop cleanly instead of retrying, and
204204 // avoid publishing an orphan sh.tangled.publicKey record to the PDS.
205205 if (!(await installationExists(installationId))) return
206206- const client = await useOAuthClient()
207207- const session = await client.restore(did)
206206+ // The user may have revoked the app on their PDS after this job was
207207+ // enqueued; the session is then gone and can't be restored. Drop cleanly
208208+ // rather than throwing and retrying to exhaustion.
209209+ const session = await restoreSessionOrNull(did)
210210+ if (!session) return
208211 if (force) await rotateKey({ oauthSession: session, installationId })
209212 else await generateAndPublishKey({ oauthSession: session, installationId })
210213 return
···386389 .where(sql`${userIdentity.installationId} = ${installationId}`)
387390 if (identity.length === 0) return null
388391389389- const client = await useOAuthClient()
390390- return client.restore(identity[0]!.did)
392392+ return restoreSessionOrNull(identity[0]!.did)
391393}
392394393395/**