Skip to content

Commit a96ea5c

Browse files
authored
fix(connectors): honor provider retry deadlines (#7211)
* fix(connectors): honor provider retry deadlines * fix(connectors): validate retry response lifecycles
1 parent db25446 commit a96ea5c

7 files changed

Lines changed: 213 additions & 46 deletions

File tree

apps/sim/lib/atlassian/discovery.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ describe('resolveAtlassianCloudId', () => {
205205
it('rejects when the token can see no sites', async () => {
206206
fetchMock.mockResolvedValue(sites([]))
207207

208-
await expect(resolveAtlassianCloudId(options())).rejects.toThrow('No Jira resources found')
208+
await expect(resolveAtlassianCloudId(options())).rejects.toThrow(
209+
'No Jira sites are accessible to this credential. Reconnect the credential and grant access to the configured Atlassian site.'
210+
)
211+
})
212+
213+
it('distinguishes a malformed discovery payload from an empty site grant', async () => {
214+
fetchMock.mockResolvedValue(createMockResponse({ json: { id: CLOUD_ID, url: SITE } }))
215+
216+
await expect(resolveAtlassianCloudId(options())).rejects.toThrow(
217+
'Invalid Jira accessible-resources response'
218+
)
219+
})
220+
221+
it.each([
222+
[{ url: SITE }],
223+
[{ id: CLOUD_ID }],
224+
[{ id: '', url: SITE }],
225+
[{ id: CLOUD_ID, url: '' }],
226+
[null],
227+
])('rejects malformed resource entries in an otherwise valid array', async (resources) => {
228+
fetchMock.mockResolvedValue(createMockResponse({ json: resources }))
229+
230+
await expect(resolveAtlassianCloudId(options())).rejects.toThrow(
231+
'Invalid Jira accessible-resources response'
232+
)
209233
})
210234
})

apps/sim/lib/atlassian/discovery.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,17 @@ interface AccessibleResource {
102102
url: string
103103
}
104104

105+
function isAccessibleResource(value: unknown): value is AccessibleResource {
106+
if (typeof value !== 'object' || value === null) return false
107+
const resource = value as Record<string, unknown>
108+
return (
109+
typeof resource.id === 'string' &&
110+
resource.id.trim().length > 0 &&
111+
typeof resource.url === 'string' &&
112+
resource.url.trim().length > 0
113+
)
114+
}
115+
105116
interface ResolveAtlassianCloudIdOptions {
106117
domain: string
107118
accessToken: string
@@ -203,21 +214,26 @@ export function selectAtlassianCloudId(
203214
domain: string,
204215
product: string
205216
): string {
206-
if (!Array.isArray(resources) || resources.length === 0) {
207-
throw new Error(`No ${product} resources found`)
217+
if (!Array.isArray(resources) || !resources.every(isAccessibleResource)) {
218+
throw new Error(`Invalid ${product} accessible-resources response`)
219+
}
220+
221+
if (resources.length === 0) {
222+
throw new Error(
223+
`No ${product} sites are accessible to this credential. ` +
224+
'Reconnect the credential and grant access to the configured Atlassian site.'
225+
)
208226
}
209227

210228
const siteUrl = normalizeAtlassianSiteUrl(domain)
211-
const match = (resources as AccessibleResource[]).find(
212-
(r) => normalizeAtlassianSiteUrl(r.url) === siteUrl
213-
)
229+
const match = resources.find((r) => normalizeAtlassianSiteUrl(r.url) === siteUrl)
214230
if (match) return match.id
215231

216-
if (resources.length === 1) return (resources as AccessibleResource[])[0].id
232+
if (resources.length === 1) return resources[0].id
217233

218234
throw new Error(
219235
`Could not match ${product} domain "${domain}" to any accessible resource. ` +
220-
`Available sites: ${(resources as AccessibleResource[]).map((r) => r.url).join(', ')}`
236+
`Available sites: ${resources.map((r) => r.url).join(', ')}`
221237
)
222238
}
223239

apps/sim/lib/knowledge/connectors/sync-engine.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,6 +2215,30 @@ describe('buildSyncFailureUpdate', () => {
22152215
expect(buildSyncFailureUpdate(now, undefined, 'boom').nextSyncAt).toEqual(minutesAfter(30))
22162216
})
22172217

2218+
it('does not schedule before a longer provider retry deadline', async () => {
2219+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
2220+
2221+
expect(buildSyncFailureUpdate(now, 0, 'rate limited', 45 * 60 * 1000).nextSyncAt).toEqual(
2222+
minutesAfter(45)
2223+
)
2224+
})
2225+
2226+
it('does not let a shorter provider delay weaken the failure backoff', async () => {
2227+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
2228+
2229+
expect(buildSyncFailureUpdate(now, 0, 'rate limited', 5 * 60 * 1000).nextSyncAt).toEqual(
2230+
minutesAfter(30)
2231+
)
2232+
})
2233+
2234+
it('caps an unreasonable provider delay at the existing one-day retry ceiling', async () => {
2235+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
2236+
2237+
expect(
2238+
buildSyncFailureUpdate(now, 0, 'rate limited', 30 * 24 * 60 * 60 * 1000).nextSyncAt
2239+
).toEqual(minutesAfter(24 * 60))
2240+
})
2241+
22182242
it('disables exactly at the threshold, not before it', async () => {
22192243
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
22202244
const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits')

apps/sim/lib/knowledge/connectors/sync-engine.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
3535
import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
3636
import {
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'
5557
import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
5658
import { StorageService } from '@/lib/uploads'
5759
import { 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
*/
13261331
export 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', {

apps/sim/lib/knowledge/documents/secure-fetch.server.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ import {
44
secureFetchWithValidation,
55
} from '@/lib/core/security/input-validation.server'
66
import {
7-
attachRetryHeaders,
8-
type HTTPError,
7+
createRetryableHttpError,
98
isRetryableError,
109
type RetryOptions,
11-
readBoundedHttpErrorBody,
12-
resolveRetryDelayMs,
1310
retryWithExponentialBackoff,
1411
} from '@/lib/knowledge/documents/utils'
1512

@@ -56,17 +53,7 @@ export async function secureFetchWithRetry(
5653
* limit) use instead.
5754
*/
5855
if (!response.ok && isRetryableError({ status: response.status, headers: response.headers })) {
59-
const errorText = await readBoundedHttpErrorBody(response)
60-
const error: HTTPError = new Error(`HTTP ${response.status} - ${errorText}`)
61-
error.status = response.status
62-
attachRetryHeaders(error, response.headers)
63-
64-
const waitMs = resolveRetryDelayMs(response.headers)
65-
if (waitMs !== undefined) {
66-
error.retryAfterMs = waitMs
67-
}
68-
69-
throw error
56+
throw await createRetryableHttpError(response)
7057
}
7158

7259
return response

apps/sim/lib/knowledge/documents/utils.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
1414
import { secureFetchWithRetry } from './secure-fetch.server'
1515
import {
1616
fetchWithRetry,
17+
getRetryAfterMs,
1718
type HTTPError,
1819
hasRateLimitEvidence,
1920
isRetryableError,
@@ -535,13 +536,37 @@ describe('fetchWithRetry rate-limit handling', () => {
535536
.mockResolvedValueOnce(response(200))
536537
globalThis.fetch = fetchMock
537538

538-
await expect(fetchWithRetry('https://api.github.com/repos', {}, FAST_RETRY)).rejects.toThrow(
539-
'HTTP 403'
539+
const error = await fetchWithRetry('https://api.github.com/repos', {}, FAST_RETRY).then(
540+
() => undefined,
541+
(caught) => caught as Error
540542
)
541543

544+
expect(error?.message).toBe('HTTP 403 - upstream rate limit exceeded')
545+
expect(getRetryAfterMs(error)).toBeGreaterThan(899_000)
546+
expect(getRetryAfterMs(error)).toBeLessThanOrEqual(900_000)
542547
expect(fetchMock).toHaveBeenCalledTimes(1)
543548
})
544549

550+
it('cancels an omitted rate-limit response body before throwing', async () => {
551+
let cancelled = false
552+
const body = new ReadableStream<Uint8Array>({
553+
cancel() {
554+
cancelled = true
555+
},
556+
})
557+
globalThis.fetch = vi.fn().mockResolvedValue(
558+
new Response(body, {
559+
status: 429,
560+
headers: { 'retry-after': '900' },
561+
})
562+
)
563+
564+
await expect(
565+
fetchWithRetry('https://api.github.com/repos', {}, { ...FAST_RETRY, maxRetries: 0 })
566+
).rejects.toThrow('HTTP 429 - upstream rate limit exceeded')
567+
expect(cancelled).toBe(true)
568+
})
569+
545570
it('waits until an admitted x-rate-limit-reset instant before retrying', async () => {
546571
vi.useFakeTimers()
547572
const now = 1_700_000_000_000
@@ -601,6 +626,20 @@ describe('fetchWithRetry rate-limit handling', () => {
601626
})
602627
})
603628

629+
describe('getRetryAfterMs', () => {
630+
it('finds a validated retry delay through an error cause chain', () => {
631+
const providerError = Object.assign(new Error('rate limited'), { retryAfterMs: 45_000 })
632+
expect(getRetryAfterMs(new Error('connector failed', { cause: providerError }))).toBe(45_000)
633+
})
634+
635+
it.each([undefined, null, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, '30000'])(
636+
'ignores an invalid retry delay: %s',
637+
(retryAfterMs) => {
638+
expect(getRetryAfterMs(Object.assign(new Error('invalid'), { retryAfterMs }))).toBeUndefined()
639+
}
640+
)
641+
})
642+
604643
describe('retryWithExponentialBackoff retry budget', () => {
605644
afterEach(() => {
606645
vi.useRealTimers()

0 commit comments

Comments
 (0)