From 5796e8682b4e9da87668d9d46f0d590fb7aebe83 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Sep 2026 18:41:00 -0700 Subject: [PATCH 1/4] fix(confluence): retry transient attachment metadata 500s and skip a failing parent instead of aborting the sync --- .../connectors/confluence/attachments.test.ts | 121 +++++++++++ apps/sim/connectors/confluence/attachments.ts | 200 ++++++++++++++++-- 2 files changed, 302 insertions(+), 19 deletions(-) diff --git a/apps/sim/connectors/confluence/attachments.test.ts b/apps/sim/connectors/confluence/attachments.test.ts index dcff9b8117e..05d97ed71fc 100644 --- a/apps/sim/connectors/confluence/attachments.test.ts +++ b/apps/sim/connectors/confluence/attachments.test.ts @@ -1,3 +1,4 @@ +import { getAllMockLoggers } from '@sim/testing/mocks/logger.mock' import JSZip from 'jszip' import { PDFDocument, StandardFonts } from 'pdf-lib' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -249,6 +250,126 @@ describe('Confluence attachment listing', () => { } ) + describe('server errors', () => { + const parents = (ids: string[]) => + vi.fn( + async (): Promise => ({ + documents: ids.map((id) => parent(id)), + hasMore: false, + }) + ) + + /** Answers a parent's attachment listing with `status` while `failing` says so. */ + function listingFails(failing: (parentId: string) => boolean, status = 500) { + fetchMock.mockImplementation(async (input) => { + const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? '' + if (failing(parentId)) return new Response('upstream error', { status }) + return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] }) + }) + } + + /** Runs a listing past the shared backoff (~31 s for five retries) without real waits. */ + async function listPastBackoff(input: Parameters[0]) { + vi.useFakeTimers() + try { + const listing = listConfluenceAttachments(input) + const settled = listing.then( + () => undefined, + () => undefined + ) + for (let i = 0; i < 20; i++) await vi.advanceTimersByTimeAsync(10_000) + await settled + return await listing + } finally { + vi.useRealTimers() + } + } + + it('retries a transient 500 instead of failing the listing', async () => { + let failed = false + listingFails(() => { + if (failed) return false + failed = true + return true + }) + const result = await listPastBackoff({ ...INPUT, listParents: parents(['p1']) }) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + 'p1', + 'attachment:page:p1:p1-file', + ]) + expect(result.listingFailures).toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('skips isolated parents whose listing keeps failing and keeps listing the rest', async () => { + listingFails((id) => id === 'p1' || id === 'p3' || id === 'p5') + const context: Record = {} + const result = await listPastBackoff({ + ...INPUT, + listParents: parents(['p1', 'p2', 'p3', 'p4', 'p5']), + syncContext: context, + }) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + 'p5', + 'attachment:page:p2:p2-file', + 'attachment:page:p4:p4-file', + ]) + expect(result.listingFailures).toEqual({ + count: 3, + samples: ['p1', 'p3', 'p5'].map((scope) => ({ + scope, + operation: 'confluence.attachments.list', + status: 500, + reasons: ['attachment_listing_unavailable'], + })), + }) + expect(result.reconciliationSafe).toBe(false) + expect(context.reconciliationUnsafe).toBe(true) + }) + + it('logs Atlassian trace identifiers for a 500 but never its body', async () => { + let failed = false + fetchMock.mockImplementation(async () => { + if (failed) return Response.json({ results: [] }) + failed = true + return Response.json( + { + errors: [{ code: 'INTERNAL_SERVER_ERROR', title: 'x', detail: 'leaked-secret-value' }], + }, + { + status: 500, + headers: { 'atl-traceid': '5c1f0e2a9b7d4e1f', 'x-arequestid': 'not an id;