Skip to content

Commit 1b20406

Browse files
authored
fix(confluence): retry transient attachment metadata 500s and skip a failing parent instead of aborting the sync (#8443)
* fix(confluence): retry transient attachment metadata 500s and skip a failing parent instead of aborting the sync * docs(confluence): list the knowledge base connector's service-account scopes, including attachments * fix(confluence): log 5xx trace ids from headers without reading the error body * fix(confluence): keep the 500 status when cancelling an errored response body
1 parent f7d0259 commit 1b20406

3 files changed

Lines changed: 305 additions & 21 deletions

File tree

‎apps/docs/content/docs/integrations/atlassian-service-account.mdx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
99
Use an Atlassian service account for Jira, Jira Service Management, and Confluence workflows. One credential can serve all three products on the same site when its account access and token scopes cover each product.
1010

1111
<Callout type="info">
12-
Setting up Search? Follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account) for its content and permission scopes. [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection.
12+
Setting up a Confluence knowledge base connector? Use the [connector scope list](#confluence-knowledge-base-connectors). For Search, follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account). [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection.
1313
</Callout>
1414

1515
## Create the account and token
@@ -56,6 +56,28 @@ read:page:confluence
5656

5757
`read:confluence-user` covers the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get). The [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get) needs `read:space:confluence`; page reads and the page picker need `read:page:confluence`.
5858

59+
### Confluence knowledge base connectors
60+
61+
A Confluence knowledge base connector reads pages, blog posts, attachments, labels, and the space permissions and page restrictions it mirrors. Its service-account token needs every scope below, not only the Confluence scopes above:
62+
63+
```text
64+
read:confluence-content.all
65+
read:page:confluence
66+
read:blogpost:confluence
67+
read:attachment:confluence
68+
read:space:confluence
69+
read:label:confluence
70+
search:confluence
71+
read:confluence-space.summary
72+
read:content.metadata:confluence
73+
read:space.permission:confluence
74+
read:confluence-user
75+
read:user:confluence
76+
read:group:confluence
77+
```
78+
79+
Without `read:attachment:confluence`, pages still sync, but their PDF, Word, Excel, and PowerPoint attachments are not indexed and each sync reports a partial source listing. To fix a token that is missing scopes, create a replacement token with the full list. A credential admin then opens the credential in **Integrations**, selects **Reconnect**, and enters the new token and site domain. Connectors that use the credential keep using it.
80+
5981
### Workflow actions
6082

6183
Add scopes for the actions your workflow performs:

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

Lines changed: 128 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,133 @@ 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.each([
289+
['a complete body', () => 'upstream error'],
290+
['a body that never finishes', () => new ReadableStream<Uint8Array>()],
291+
[
292+
'a body stream that already errored',
293+
() =>
294+
new ReadableStream<Uint8Array>({
295+
start: (controller) => controller.error(new Error('connection reset')),
296+
}),
297+
],
298+
])('retries a transient 500 with %s instead of failing the listing', async (_, body) => {
299+
let failed = false
300+
fetchMock.mockImplementation(async () => {
301+
if (failed) return Response.json({ results: [file({ id: 'p1-file' })] })
302+
failed = true
303+
return new Response(body(), { status: 500 })
304+
})
305+
const result = await listPastBackoff({ ...INPUT, listParents: parents(['p1']) })
306+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
307+
'p1',
308+
'attachment:page:p1:p1-file',
309+
])
310+
expect(result.listingFailures).toBeUndefined()
311+
})
312+
313+
it('skips isolated parents whose listing keeps failing and keeps listing the rest', async () => {
314+
listingFails((id) => id === 'p1' || id === 'p3' || id === 'p5')
315+
const context: Record<string, unknown> = {}
316+
const result = await listPastBackoff({
317+
...INPUT,
318+
listParents: parents(['p1', 'p2', 'p3', 'p4', 'p5']),
319+
syncContext: context,
320+
})
321+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
322+
'p1',
323+
'p2',
324+
'p3',
325+
'p4',
326+
'p5',
327+
'attachment:page:p2:p2-file',
328+
'attachment:page:p4:p4-file',
329+
])
330+
expect(result.listingFailures).toEqual({
331+
count: 3,
332+
samples: ['p1', 'p3', 'p5'].map((scope) => ({
333+
scope,
334+
operation: 'confluence.attachments.list',
335+
status: 500,
336+
reasons: ['attachment_listing_unavailable'],
337+
})),
338+
})
339+
expect(result.reconciliationSafe).toBe(false)
340+
expect(context.reconciliationUnsafe).toBe(true)
341+
})
342+
343+
it('logs Atlassian trace identifiers for a 500 but never its body', async () => {
344+
let failed = false
345+
fetchMock.mockImplementation(async () => {
346+
if (failed) return Response.json({ results: [] })
347+
failed = true
348+
return Response.json(
349+
{
350+
errors: [{ code: 'INTERNAL_SERVER_ERROR', title: 'x', detail: 'leaked-secret-value' }],
351+
},
352+
{
353+
status: 500,
354+
headers: { 'atl-traceid': '5c1f0e2a9b7d4e1f', 'x-arequestid': 'not an id; <script>' },
355+
}
356+
)
357+
})
358+
await listPastBackoff({ ...INPUT, listParents: parents(['p1']) })
359+
const logged = JSON.stringify(
360+
getAllMockLoggers().flatMap((logger) =>
361+
[logger.info, logger.warn, logger.error, logger.debug].flatMap((fn) => fn.mock.calls)
362+
)
363+
)
364+
expect(logged).toContain('5c1f0e2a9b7d4e1f')
365+
expect(logged).not.toContain('<script>')
366+
expect(logged).not.toContain('leaked-secret-value')
367+
})
368+
369+
it.each([500, 503])(
370+
'fails the sync when consecutive parents answer %s, as during an outage',
371+
async (status) => {
372+
listingFails(() => true, status)
373+
await expect(
374+
listPastBackoff({ ...INPUT, listParents: parents(['p1', 'p2', 'p3', 'p4']) })
375+
).rejects.toMatchObject({ status })
376+
}
377+
)
378+
})
379+
252380
it('surfaces known oversized files as skipped without downloading', async () => {
253381
fetchMock.mockResolvedValue(
254382
Response.json({ results: [file({ fileSize: CONNECTOR_MAX_FILE_BYTES + 1 })] })

0 commit comments

Comments
 (0)