@@ -57,6 +57,7 @@ import {
5757 CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES ,
5858 CONNECTOR_SYNC_MAX_DURATION_SECONDS ,
5959 CREDENTIAL_REMOVED_SYNC_ERROR ,
60+ CREDENTIAL_REVOKED_SYNC_ERROR ,
6061 connectorFailureBackoffMinutes ,
6162 MAX_CONSECUTIVE_FAILURES ,
6263} from '@/lib/knowledge/connectors/sync-limits'
@@ -88,6 +89,7 @@ import {
8889import { hardDeleteDocuments } from '@/lib/knowledge/documents/service'
8990import { getRetryAfterMs , isRateLimitError } from '@/lib/knowledge/documents/utils'
9091import { ensureSourceVectorIndex } from '@/lib/knowledge/search/source-vector-indexes'
92+ import { getCredentialTerminalRefreshError } from '@/lib/oauth/credential-service'
9193import { connectorHasAuthSource } from '@/connectors/auth'
9294import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
9395import type {
@@ -743,9 +745,26 @@ export function buildSyncSuccessUpdate(
743745 }
744746}
745747
748+ /**
749+ * A credential the source rejected outright: the refresh path recorded a terminal error for
750+ * it, so no retry can produce a token until the credential is reauthorized.
751+ */
752+ export class ConnectorCredentialRevokedError extends Error {
753+ constructor (
754+ readonly credentialId : string ,
755+ readonly errorCode : string
756+ ) {
757+ super ( `Credential ${ credentialId } was rejected by the source (${ errorCode } )` )
758+ this . name = 'ConnectorCredentialRevokedError'
759+ }
760+ }
761+
746762/**
747763 * Resolves the token a connector syncs with, failing loudly where the shared
748764 * resolver reports "no token" — a sync has no reconnect prompt to fall back to.
765+ * A credential the source has rejected outright fails as
766+ * {@link ConnectorCredentialRevokedError}, so the run can unschedule the
767+ * connector instead of walking the failure ladder toward a retry that cannot help.
749768 */
750769async function resolveAccessToken (
751770 connector : { credentialId : string | null ; encryptedApiKey : string | null } ,
@@ -770,6 +789,13 @@ async function resolveAccessToken(
770789 userId,
771790 authMode : connectorConfig . auth . mode ,
772791 } )
792+ const terminalError =
793+ connectorConfig . auth . mode === 'oauth' && connector . credentialId
794+ ? await getCredentialTerminalRefreshError ( connector . credentialId )
795+ : null
796+ if ( terminalError && connector . credentialId ) {
797+ throw new ConnectorCredentialRevokedError ( connector . credentialId , terminalError )
798+ }
773799 throw new Error ( `Failed to obtain access token for credential ${ connector . credentialId } ` )
774800 }
775801
@@ -1427,6 +1453,46 @@ export async function executeSync(
14271453 }
14281454 }
14291455
1456+ if ( error instanceof ConnectorCredentialRevokedError ) {
1457+ /**
1458+ * Retrying cannot help until the credential is reauthorized, so the
1459+ * connector leaves its schedule with a reconnect prompt instead of
1460+ * climbing the failure ladder toward the same rejection. Reauthorizing
1461+ * the credential puts it back on schedule. The run itself is a skip:
1462+ * nothing about the source failed, and a sync that cannot start is not
1463+ * an incident to page on.
1464+ */
1465+ logger . warn ( 'Sync unscheduled: the source rejected the connector credential' , {
1466+ connectorId,
1467+ credentialId : error . credentialId ,
1468+ errorCode : error . errorCode ,
1469+ } )
1470+ try {
1471+ await completeSyncLog ( syncLogId , 'failed' , result , {
1472+ errorMessage : CREDENTIAL_REVOKED_SYNC_ERROR ,
1473+ } )
1474+ const landed = await writeTerminalConnectorState (
1475+ connectorId ,
1476+ syncLogId ,
1477+ buildSyncUnscheduledUpdate ( new Date ( ) , CREDENTIAL_REVOKED_SYNC_ERROR )
1478+ )
1479+ if ( ! landed ) {
1480+ logger . warn (
1481+ 'Unschedule discarded — connector was reclaimed while this run was executing' ,
1482+ { connectorId, syncLogId }
1483+ )
1484+ }
1485+ } catch ( recoveryError ) {
1486+ logger . error ( 'Failed to unschedule the connector' , {
1487+ connectorId,
1488+ error :
1489+ getConnectorFailureDiagnostic ( recoveryError ) ?. message ??
1490+ toError ( recoveryError ) . message ,
1491+ } )
1492+ }
1493+ return { ...result , skipReason : 'credential_revoked' }
1494+ }
1495+
14301496 const diagnostic = getConnectorFailureDiagnostic ( error )
14311497 const errorMessage = diagnostic ?. message ?? toError ( error ) . message
14321498 const retryAfterMs = getRetryAfterMs ( error )
0 commit comments