Skip to content

Commit 5796e86

Browse files
committed
fix(confluence): retry transient attachment metadata 500s and skip a failing parent instead of aborting the sync
1 parent 3841b94 commit 5796e86

2 files changed

Lines changed: 302 additions & 19 deletions

File tree

‎apps/sim/connectors/confluence/attachments.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getAllMockLoggers } from '@sim/testing/mocks/logger.mock'
12
import JSZip from 'jszip'
23
import { PDFDocument, StandardFonts } from 'pdf-lib'
34
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -249,6 +250,126 @@ describe('Confluence attachment listing', () => {
249250
}
250251
)
251252

253+
describe('server errors', () => {
254+
const parents = (ids: string[]) =>
255+
vi.fn(
256+
async (): Promise<ExternalDocumentList> => ({
257+
documents: ids.map((id) => parent(id)),
258+
hasMore: false,
259+
})
260+
)
261+
262+
/** Answers a parent's attachment listing with `status` while `failing` says so. */
263+
function listingFails(failing: (parentId: string) => boolean, status = 500) {
264+
fetchMock.mockImplementation(async (input) => {
265+
const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? ''
266+
if (failing(parentId)) return new Response('upstream error', { status })
267+
return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] })
268+
})
269+
}
270+
271+
/** Runs a listing past the shared backoff (~31 s for five retries) without real waits. */
272+
async function listPastBackoff(input: Parameters<typeof listConfluenceAttachments>[0]) {
273+
vi.useFakeTimers()
274+
try {
275+
const listing = listConfluenceAttachments(input)
276+
const settled = listing.then(
277+
() => undefined,
278+
() => undefined
279+
)
280+
for (let i = 0; i < 20; i++) await vi.advanceTimersByTimeAsync(10_000)
281+
await settled
282+
return await listing
283+
} finally {
284+
vi.useRealTimers()
285+
}
286+
}
287+
288+
it('retries a transient 500 instead of failing the listing', async () => {
289+
let failed = false
290+
listingFails(() => {
291+
if (failed) return false
292+
failed = true
293+
return true
294+
})
295+
const result = await listPastBackoff({ ...INPUT, listParents: parents(['p1']) })
296+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
297+
'p1',
298+
'attachment:page:p1:p1-file',
299+
])
300+
expect(result.listingFailures).toBeUndefined()
301+
expect(fetchMock).toHaveBeenCalledTimes(2)
302+
})
303+
304+
it('skips isolated parents whose listing keeps failing and keeps listing the rest', async () => {
305+
listingFails((id) => id === 'p1' || id === 'p3' || id === 'p5')
306+
const context: Record<string, unknown> = {}
307+
const result = await listPastBackoff({
308+
...INPUT,
309+
listParents: parents(['p1', 'p2', 'p3', 'p4', 'p5']),
310+
syncContext: context,
311+
})
312+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
313+
'p1',
314+
'p2',
315+
'p3',
316+
'p4',
317+
'p5',
318+
'attachment:page:p2:p2-file',
319+
'attachment:page:p4:p4-file',
320+
])
321+
expect(result.listingFailures).toEqual({
322+
count: 3,
323+
samples: ['p1', 'p3', 'p5'].map((scope) => ({
324+
scope,
325+
operation: 'confluence.attachments.list',
326+
status: 500,
327+
reasons: ['attachment_listing_unavailable'],
328+
})),
329+
})
330+
expect(result.reconciliationSafe).toBe(false)
331+
expect(context.reconciliationUnsafe).toBe(true)
332+
})
333+
334+
it('logs Atlassian trace identifiers for a 500 but never its body', async () => {
335+
let failed = false
336+
fetchMock.mockImplementation(async () => {
337+
if (failed) return Response.json({ results: [] })
338+
failed = true
339+
return Response.json(
340+
{
341+
errors: [{ code: 'INTERNAL_SERVER_ERROR', title: 'x', detail: 'leaked-secret-value' }],
342+
},
343+
{
344+
status: 500,
345+
headers: { 'atl-traceid': '5c1f0e2a9b7d4e1f', 'x-arequestid': 'not an id; <script>' },
346+
}
347+
)
348+
})
349+
await listPastBackoff({ ...INPUT, listParents: parents(['p1']) })
350+
const logged = JSON.stringify(
351+
getAllMockLoggers().flatMap((logger) =>
352+
[logger.info, logger.warn, logger.error, logger.debug].flatMap((fn) => fn.mock.calls)
353+
)
354+
)
355+
expect(logged).toContain('5c1f0e2a9b7d4e1f')
356+
expect(logged).toContain('INTERNAL_SERVER_ERROR')
357+
expect(logged).not.toContain('<script>')
358+
expect(logged).not.toContain('leaked-secret-value')
359+
})
360+
361+
it.each([500, 503])(
362+
'fails the sync when consecutive parents answer %s, as during an outage',
363+
async (status) => {
364+
listingFails(() => true, status)
365+
await expect(
366+
listPastBackoff({ ...INPUT, listParents: parents(['p1', 'p2', 'p3', 'p4']) })
367+
).rejects.toMatchObject({ status })
368+
expect(fetchMock).toHaveBeenCalledTimes(3 * 6)
369+
}
370+
)
371+
})
372+
252373
it('surfaces known oversized files as skipped without downloading', async () => {
253374
fetchMock.mockResolvedValue(
254375
Response.json({ results: [file({ fileSize: CONNECTOR_MAX_FILE_BYTES + 1 })] })

0 commit comments

Comments
 (0)