Skip to content

Commit fd270d8

Browse files
committed
fix(connectors): honor provider retry deadlines
1 parent db25446 commit fd270d8

7 files changed

Lines changed: 152 additions & 41 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ 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+
)
209219
})
210220
})

apps/sim/lib/atlassian/discovery.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,15 @@ export function selectAtlassianCloudId(
203203
domain: string,
204204
product: string
205205
): string {
206-
if (!Array.isArray(resources) || resources.length === 0) {
207-
throw new Error(`No ${product} resources found`)
206+
if (!Array.isArray(resources)) {
207+
throw new Error(`Invalid ${product} accessible-resources response`)
208+
}
209+
210+
if (resources.length === 0) {
211+
throw new Error(
212+
`No ${product} sites are accessible to this credential. ` +
213+
'Reconnect the credential and grant access to the configured Atlassian site.'
214+
)
208215
}
209216

210217
const siteUrl = normalizeAtlassianSiteUrl(domain)

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: 21 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,10 +536,14 @@ 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

@@ -601,6 +606,20 @@ describe('fetchWithRetry rate-limit handling', () => {
601606
})
602607
})
603608

609+
describe('getRetryAfterMs', () => {
610+
it('finds a validated retry delay through an error cause chain', () => {
611+
const providerError = Object.assign(new Error('rate limited'), { retryAfterMs: 45_000 })
612+
expect(getRetryAfterMs(new Error('connector failed', { cause: providerError }))).toBe(45_000)
613+
})
614+
615+
it.each([undefined, null, 0, -1, Number.NaN, Number.POSITIVE_INFINITY, '30000'])(
616+
'ignores an invalid retry delay: %s',
617+
(retryAfterMs) => {
618+
expect(getRetryAfterMs(Object.assign(new Error('invalid'), { retryAfterMs }))).toBeUndefined()
619+
}
620+
)
621+
})
622+
604623
describe('retryWithExponentialBackoff retry budget', () => {
605624
afterEach(() => {
606625
vi.useRealTimers()

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

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,30 @@ export function attachRetryHeaders(error: HTTPError, headers: HeaderReader): voi
183183
})
184184
}
185185

186+
/**
187+
* Reads a validated provider retry delay from an error or one of its causes.
188+
*
189+
* The HTTP retry layer attaches this value when a provider supplies
190+
* `Retry-After` or an exhausted-quota reset header. Keeping the accessor here
191+
* lets longer-lived schedulers honor the same evidence without depending on a
192+
* concrete error class or parsing a diagnostic message.
193+
*/
194+
export function getRetryAfterMs(error: unknown): number | undefined {
195+
const seen = new Set<unknown>()
196+
let current = error
197+
198+
while (current instanceof Error && !seen.has(current) && seen.size < 10) {
199+
seen.add(current)
200+
const retryAfterMs = (current as HTTPError).retryAfterMs
201+
if (typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
202+
return retryAfterMs
203+
}
204+
current = current.cause
205+
}
206+
207+
return undefined
208+
}
209+
186210
/**
187211
* True when response headers positively identify a rate-limit rejection rather
188212
* than an authorization denial.
@@ -254,6 +278,39 @@ export function resolveRetryDelayMs(
254278
return undefined
255279
}
256280

281+
interface RetryableHttpResponse {
282+
status: number
283+
headers: { get(name: string): string | null }
284+
body?: ReadableStream<Uint8Array> | null
285+
arrayBuffer?: () => Promise<ArrayBuffer>
286+
text?: () => Promise<string>
287+
}
288+
289+
/**
290+
* Builds the bounded error shared by direct and SSRF-safe connector fetches.
291+
* Rate-limit responses are named from trusted status/header evidence while all
292+
* provider-controlled bodies remain omitted.
293+
*/
294+
export async function createRetryableHttpError(
295+
response: RetryableHttpResponse
296+
): Promise<HTTPError> {
297+
const rateLimited =
298+
response.status === 429 || (response.status === 403 && hasRateLimitEvidence(response.headers))
299+
const diagnostic = rateLimited
300+
? 'upstream rate limit exceeded'
301+
: await readBoundedHttpErrorBody(response)
302+
const error: HTTPError = new Error(`HTTP ${response.status} - ${diagnostic}`)
303+
error.status = response.status
304+
attachRetryHeaders(error, response.headers)
305+
306+
const waitMs = resolveRetryDelayMs(response.headers)
307+
if (waitMs !== undefined) {
308+
error.retryAfterMs = waitMs
309+
}
310+
311+
return error
312+
}
313+
257314
/**
258315
* Default retry condition for rate limiting errors
259316
*/
@@ -471,22 +528,7 @@ export async function fetchWithRetry(
471528
const response = await fetch(url, options)
472529

473530
if (!response.ok && isRetryableError({ status: response.status, headers: response.headers })) {
474-
const errorText = await readBoundedHttpErrorBody(response)
475-
const error: HTTPError = new Error(`HTTP ${response.status} - ${errorText}`)
476-
error.status = response.status
477-
// The retry loop re-runs the retry condition against this error, so the
478-
// headers must travel with it or a rate-limit 403 would throw immediately.
479-
attachRetryHeaders(error, response.headers)
480-
481-
// Pass the server-stated wait to the retry loop so it replaces exponential
482-
// backoff. Falls back to the epoch-seconds reset header when the provider
483-
// sends no Retry-After (X never does).
484-
const waitMs = resolveRetryDelayMs(response.headers)
485-
if (waitMs !== undefined) {
486-
error.retryAfterMs = waitMs
487-
}
488-
489-
throw error
531+
throw await createRetryableHttpError(response)
490532
}
491533

492534
return response

0 commit comments

Comments
 (0)