@@ -35,6 +35,7 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
3535import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
3636import {
3737 CONNECTOR_AUTO_DISABLED_ERROR ,
38+ CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES ,
3839 connectorFailureBackoffMinutes ,
3940 MAX_CONSECUTIVE_FAILURES ,
4041 SYNC_LOCK_HEARTBEAT_INTERVAL_MS ,
@@ -52,6 +53,7 @@ import {
5253 MAX_PROCESSING_ATTEMPTS ,
5354 QUEUED_DISPATCH_GRACE_MS ,
5455} from '@/lib/knowledge/documents/types'
56+ import { getRetryAfterMs } from '@/lib/knowledge/documents/utils'
5557import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
5658import { StorageService } from '@/lib/uploads'
5759import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key'
@@ -1321,22 +1323,32 @@ export function buildReconciliationHoldNotice(
13211323 * it applies need to be assertable without standing up the whole sync. The
13221324 * in-process ladder here and the reaper's SQL ladder must agree — they are two
13231325 * writers of one policy, both sourced from
1324- * {@link connectorFailureBackoffMinutes}.
1326+ * {@link connectorFailureBackoffMinutes}. A validated provider retry delay is
1327+ * an additional lower bound, capped at the same one-day ceiling: a short hint
1328+ * cannot weaken the failure ladder, while an untrusted extreme value cannot
1329+ * pin the connector indefinitely.
13251330 */
13261331export function buildSyncFailureUpdate (
13271332 now : Date ,
13281333 previousFailures : number | null | undefined ,
1329- errorMessage : string
1334+ errorMessage : string ,
1335+ retryAfterMs ?: number
13301336) {
13311337 const failures = ( previousFailures ?? 0 ) + 1
13321338 const disabled = failures >= MAX_CONSECUTIVE_FAILURES
1339+ const failureBackoffMs = connectorFailureBackoffMinutes ( failures ) * 60 * 1000
1340+ const maximumBackoffMs = CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES * 60 * 1000
1341+ const providerBackoffMs =
1342+ typeof retryAfterMs === 'number' && Number . isFinite ( retryAfterMs ) && retryAfterMs > 0
1343+ ? Math . min ( retryAfterMs , maximumBackoffMs )
1344+ : 0
13331345
13341346 return {
13351347 status : ( disabled ? 'disabled' : 'error' ) as 'disabled' | 'error' ,
13361348 lastSyncError : disabled ? CONNECTOR_AUTO_DISABLED_ERROR : errorMessage ,
13371349 nextSyncAt : disabled
13381350 ? null
1339- : new Date ( now . getTime ( ) + connectorFailureBackoffMinutes ( failures ) * 60 * 1000 ) ,
1351+ : new Date ( now . getTime ( ) + Math . max ( failureBackoffMs , providerBackoffMs ) ) ,
13401352 consecutiveFailures : failures ,
13411353 // Releases the lock so a stale token can never match a later run, and closes
13421354 // its lease so the reaper is not left waiting out a TTL on a finished run.
@@ -3160,15 +3172,25 @@ export async function executeSync(
31603172 }
31613173
31623174 const errorMessage = toError ( error ) . message
3163- logger . error ( 'Sync failed' , { connectorId, error : errorMessage } )
3175+ const retryAfterMs = getRetryAfterMs ( error )
3176+ logger . error ( 'Sync failed' , {
3177+ connectorId,
3178+ error : errorMessage ,
3179+ ...( retryAfterMs === undefined ? { } : { retryAfterMs } ) ,
3180+ } )
31643181
31653182 try {
31663183 await completeSyncLog ( syncLogId , 'failed' , result , { errorMessage } )
31673184
31683185 const failureUpdate =
31693186 error instanceof ConnectorSyncCapacityError
31703187 ? buildSyncCapacityUpdate ( new Date ( ) , connector . consecutiveFailures , errorMessage )
3171- : buildSyncFailureUpdate ( new Date ( ) , connector . consecutiveFailures , errorMessage )
3188+ : buildSyncFailureUpdate (
3189+ new Date ( ) ,
3190+ connector . consecutiveFailures ,
3191+ errorMessage ,
3192+ retryAfterMs
3193+ )
31723194
31733195 if ( failureUpdate . status === 'disabled' ) {
31743196 logger . warn ( 'Connector disabled after repeated failures' , {
0 commit comments