Skip to content

Commit 15e1008

Browse files
committed
fix(atlassian): key discovery answers by credential and retry timeouts
Review round 1. Three fixes. The cache keyed on the normalized domain alone, so a caller joining a lookup already in flight inherited whichever credential started it — taking that token's authorization failure, or its single-site fallback pointing at a different site. Retaining only exact matches closed that for settled entries but not for the in-flight window, which is where it actually bites. Keys now carry a digest of the access token, so an answer is only ever reused by the credential that earned it. That also removes the reason the cache needed a `retain` channel. The request's own `AbortSignal.timeout` rejects with a `TimeoutError` that has no status and no message the shared predicate matches, so a slow site failed on the first attempt despite the retry budget. It is now explicitly retryable — only `TimeoutError`, since an `AbortError` means a caller cancelled — and the per- request timeout drops to 5s so four attempts stay bounded. Jira bulk read had been pointed at the cached resolver, but the tool's own configured request IS the discovery call and `transformResponse` only runs on a 2xx. It was therefore re-issuing a request whose answer it already held. It now matches against that payload through the shared selector, so the matching logic stays in one place without a second round trip.
1 parent cb0de8e commit 15e1008

4 files changed

Lines changed: 103 additions & 44 deletions

File tree

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,45 @@ describe('resolveAtlassianCloudId', () => {
142142
).rejects.toThrow(/Failed to fetch Confluence accessible resources: 500/)
143143
})
144144

145-
it('does not retain a single-site fallback, since it is token-specific', async () => {
146-
fetchMock.mockImplementation(async () =>
147-
sites([{ id: 'other-cloud', url: 'https://other.atlassian.net' }])
145+
it('does not serve one credential answer to another', async () => {
146+
fetchMock
147+
.mockResolvedValueOnce(sites([{ id: 'token-a-cloud', url: SITE }]))
148+
.mockResolvedValueOnce(sites([{ id: 'token-b-cloud', url: SITE }]))
149+
150+
await expect(resolveAtlassianCloudId(options({ accessToken: 'a' }))).resolves.toBe(
151+
'token-a-cloud'
148152
)
153+
await expect(resolveAtlassianCloudId(options({ accessToken: 'b' }))).resolves.toBe(
154+
'token-b-cloud'
155+
)
156+
expect(fetchMock).toHaveBeenCalledTimes(2)
157+
})
149158

150-
await expect(resolveAtlassianCloudId(options())).resolves.toBe('other-cloud')
151-
await resolveAtlassianCloudId(options())
159+
it('does not let a concurrent caller inherit another credential lookup', async () => {
160+
// Token A sees only a different site, so it falls back; token B matches exactly.
161+
// Joining A's in-flight promise would hand B the wrong site.
162+
fetchMock
163+
.mockResolvedValueOnce(sites([{ id: 'a-only-cloud', url: 'https://other.atlassian.net' }]))
164+
.mockResolvedValueOnce(sites([{ id: CLOUD_ID, url: SITE }]))
165+
166+
const [a, b] = await Promise.all([
167+
resolveAtlassianCloudId(options({ accessToken: 'a' })),
168+
resolveAtlassianCloudId(options({ accessToken: 'b' })),
169+
])
170+
171+
expect(a).toBe('a-only-cloud')
172+
expect(b).toBe(CLOUD_ID)
173+
expect(fetchMock).toHaveBeenCalledTimes(2)
174+
})
175+
176+
it('retries a request that timed out', async () => {
177+
fetchMock
178+
.mockRejectedValueOnce(
179+
Object.assign(new Error('The operation timed out.'), { name: 'TimeoutError' })
180+
)
181+
.mockResolvedValueOnce(sites([{ id: CLOUD_ID, url: SITE }]))
152182

183+
await expect(resolveAtlassianCloudId(options({ retryOptions: FAST }))).resolves.toBe(CLOUD_ID)
153184
expect(fetchMock).toHaveBeenCalledTimes(2)
154185
})
155186

apps/sim/lib/atlassian/discovery.ts

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sha256Hex } from '@sim/security/hash'
12
import { parseRetryAfter } from '@sim/utils/retry'
23
import { LRUCache } from 'lru-cache'
34
import {
@@ -9,7 +10,7 @@ import {
910

1011
const ACCESSIBLE_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'
1112

12-
const DISCOVERY_REQUEST_TIMEOUT_MS = 10_000
13+
const DISCOVERY_REQUEST_TIMEOUT_MS = 5_000
1314

1415
/**
1516
* A site's `cloudId` is a property of the site rather than of the caller — the
@@ -41,25 +42,20 @@ export function createAtlassianDiscoveryCache() {
4142
*
4243
* The promise — not the resolved value — is stored, so callers arriving while
4344
* a lookup is in flight join it instead of starting their own, and a rejection
44-
* is evicted rather than pinned for the TTL. `retain: false` drops the entry
45-
* once it settles, so a token-specific result is not served to later callers.
45+
* is evicted rather than pinned for the TTL.
46+
*
47+
* `key` must identify the credential as well as the resource — a joined caller
48+
* receives the first caller's outcome, so a domain-only key would let one
49+
* token's authorization failure or single-site fallback answer another's.
4650
*/
47-
resolve(
48-
key: string,
49-
resolver: () => Promise<{ value: string; retain: boolean }>
50-
): Promise<string> {
51+
resolve(key: string, resolver: () => Promise<string>): Promise<string> {
5152
const cached = cache.get(key)
5253
if (cached) return cached
5354

54-
const promise = resolver()
55-
.then(({ value, retain }) => {
56-
if (!retain) cache.delete(key)
57-
return value
58-
})
59-
.catch((error) => {
60-
cache.delete(key)
61-
throw error
62-
})
55+
const promise = resolver().catch((error) => {
56+
cache.delete(key)
57+
throw error
58+
})
6359

6460
cache.set(key, promise)
6561
return promise
@@ -91,6 +87,11 @@ export const ATLASSIAN_DISCOVERY_RETRY_OPTIONS: RetryOptions = {
9187
maxDelayMs: 8000,
9288
retryCondition: (error) => {
9389
if (isRetryableError(error)) return true
90+
// The request's own `AbortSignal.timeout` rejects with a `TimeoutError` that
91+
// carries no status and no message the shared predicate matches, so without
92+
// this a slow site would fail on the first attempt. Only `TimeoutError` — an
93+
// `AbortError` would mean a caller cancelled and does not want a replay.
94+
if (error instanceof Error && error.name === 'TimeoutError') return true
9495
const status = (error as { status?: unknown } | null)?.status
9596
return typeof status === 'number' && status >= 500
9697
},
@@ -181,34 +182,47 @@ function fetchAccessibleResources(
181182
)
182183
}
183184

184-
async function discoverCloudId(
185-
siteUrl: string,
186-
{ domain, accessToken, product, retryOptions }: ResolveAtlassianCloudIdOptions
187-
): Promise<{ value: string; retain: boolean }> {
188-
const resources = await fetchAccessibleResources(accessToken, product, retryOptions)
185+
/**
186+
* Cache key for a discovery answer, scoped to the credential that produced it.
187+
*
188+
* What `accessible-resources` reports depends on the token, so an answer is only
189+
* reusable by the same token. The digest keeps the raw token out of the key.
190+
*/
191+
export function atlassianDiscoveryKey(resource: string, accessToken: string): string {
192+
return `${resource}:${sha256Hex(accessToken).slice(0, 16)}`
193+
}
189194

195+
/**
196+
* Picks the `cloudId` for `domain` out of an `accessible-resources` payload.
197+
*
198+
* Separate from the fetch so a caller that already holds the payload can match
199+
* against it instead of issuing the request a second time.
200+
*/
201+
export function selectAtlassianCloudId(
202+
resources: unknown,
203+
domain: string,
204+
product: string
205+
): string {
190206
if (!Array.isArray(resources) || resources.length === 0) {
191207
throw new Error(`No ${product} resources found`)
192208
}
193209

194-
const match = resources.find((r) => normalizeAtlassianSiteUrl(r.url) === siteUrl)
195-
if (match) {
196-
return { value: match.id, retain: true }
197-
}
210+
const siteUrl = normalizeAtlassianSiteUrl(domain)
211+
const match = (resources as AccessibleResource[]).find(
212+
(r) => normalizeAtlassianSiteUrl(r.url) === siteUrl
213+
)
214+
if (match) return match.id
198215

199-
// A single-site fallback is a property of this token, not of the domain.
200-
if (resources.length === 1) {
201-
return { value: resources[0].id, retain: false }
202-
}
216+
if (resources.length === 1) return (resources as AccessibleResource[])[0].id
203217

204218
throw new Error(
205219
`Could not match ${product} domain "${domain}" to any accessible resource. ` +
206-
`Available sites: ${resources.map((r) => r.url).join(', ')}`
220+
`Available sites: ${(resources as AccessibleResource[]).map((r) => r.url).join(', ')}`
207221
)
208222
}
209223

210224
/**
211-
* Resolves the Atlassian `cloudId` for a site domain, memoized across callers.
225+
* Resolves the Atlassian `cloudId` for a site domain, memoized per credential.
212226
*
213227
* Jira, Confluence, and JSM all read the same `accessible-resources` endpoint, so
214228
* a run touching several Atlassian blocks shares one round trip and one retry
@@ -217,8 +231,16 @@ async function discoverCloudId(
217231
export async function resolveAtlassianCloudId(
218232
options: ResolveAtlassianCloudIdOptions
219233
): Promise<string> {
220-
const siteUrl = normalizeAtlassianSiteUrl(options.domain)
221-
return cloudIdCache.resolve(siteUrl, () => discoverCloudId(siteUrl, options))
234+
const { domain, accessToken, product, retryOptions } = options
235+
const key = atlassianDiscoveryKey(normalizeAtlassianSiteUrl(domain), accessToken)
236+
237+
return cloudIdCache.resolve(key, async () =>
238+
selectAtlassianCloudId(
239+
await fetchAccessibleResources(accessToken, product, retryOptions),
240+
domain,
241+
product
242+
)
243+
)
222244
}
223245

224246
/** Drops every memoized `cloudId`. Exists for tests. */

apps/sim/tools/jira/bulk_read.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { selectAtlassianCloudId } from '@/lib/atlassian/discovery'
12
import type { JiraRetrieveBulkParams, JiraRetrieveResponseBulk } from '@/tools/jira/types'
23
import { TIMESTAMP_OUTPUT } from '@/tools/jira/types'
3-
import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils'
4+
import { extractAdfText } from '@/tools/jira/utils'
45
import type { ToolConfig } from '@/tools/types'
56

67
export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrieveResponseBulk> = {
@@ -51,7 +52,7 @@ export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrie
5152
}),
5253
},
5354

54-
transformResponse: async (_response: Response, params?: JiraRetrieveBulkParams) => {
55+
transformResponse: async (response: Response, params?: JiraRetrieveBulkParams) => {
5556
const MAX_TOTAL = 1000
5657
const PAGE_SIZE = 100
5758

@@ -68,8 +69,11 @@ export const jiraBulkRetrieveTool: ToolConfig<JiraRetrieveBulkParams, JiraRetrie
6869
return project?.key || refTrimmed
6970
}
7071

72+
// The dispatcher's configured request IS the discovery call, and it only
73+
// reaches here on a 2xx — so match against that payload rather than issuing
74+
// the same request again through the cached resolver.
7175
const cloudId =
72-
params?.cloudId ?? (await getJiraCloudId(params?.domain ?? '', params!.accessToken))
76+
params?.cloudId ?? selectAtlassianCloudId(await response.json(), params?.domain ?? '', 'Jira')
7377
const projectKey = await resolveProjectKey(cloudId, params!.accessToken, params!.projectId)
7478
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(projectKey)) {
7579
throw new Error(

apps/sim/tools/jsm/utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import {
6+
atlassianDiscoveryKey,
67
createAtlassianDiscoveryCache,
78
fetchAtlassianDiscoveryJson,
89
} from '@/lib/atlassian/discovery'
@@ -11,7 +12,8 @@ import type { AssetObject, RawAssetObject } from '@/tools/jsm/types'
1112

1213
/**
1314
* Atlassian provisions a single Assets workspace per site, so the answer is a
14-
* property of the `cloudId` and safe to share across callers.
15+
* property of the `cloudId` — but whether a token may read it is not, so entries
16+
* are keyed by credential like every other discovery answer.
1517
*/
1618
const assetsWorkspaceCache = createAtlassianDiscoveryCache()
1719

@@ -124,7 +126,7 @@ export function getAssetsApiBaseUrl(cloudId: string, workspaceId: string): strin
124126
* @throws If discovery fails or no workspace is provisioned
125127
*/
126128
export function getAssetsWorkspaceId(cloudId: string, accessToken: string): Promise<string> {
127-
return assetsWorkspaceCache.resolve(cloudId, async () => {
129+
return assetsWorkspaceCache.resolve(atlassianDiscoveryKey(cloudId, accessToken), async () => {
128130
const data = await fetchAtlassianDiscoveryJson<{ values?: Array<{ workspaceId?: string }> }>(
129131
`https://api.atlassian.com/ex/jira/${cloudId}/rest/servicedeskapi/assets/workspace`,
130132
getJsmHeaders(accessToken),
@@ -139,6 +141,6 @@ export function getAssetsWorkspaceId(cloudId: string, accessToken: string): Prom
139141
)
140142
}
141143

142-
return { value: workspaceId, retain: true }
144+
return workspaceId
143145
})
144146
}

0 commit comments

Comments
 (0)