Skip to content

Commit 2071f2d

Browse files
author
figma-bot
committed
Code Connect v1.4.7
1 parent f04ec24 commit 2071f2d

5 files changed

Lines changed: 291 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
# Code Connect v1.4.6 (22 May 2026)
1+
# Code Connect v1.4.7 (28 May 2026)
22

33
## Features
44

55
### General
66

77
- The `--file` (`-f`) option on `figma connect publish`, `unpublish`, and `parse` now accepts multiple files, so you can target several Code Connect files in one command (e.g. `figma connect publish --file a.figma.ts b.figma.ts`). Previously only a single file path was accepted.
8+
- Tweak retry behavior for `publish` command to reduce 429s errors
89
- Shared flags (`--verbose`, `--token`, `--config`, `--dir`, `--file`, `--out-file`, `--out-dir`, `--api-url`, `--skip-update-check`, `--exit-on-unreadable-files`, `--dry-run`) now work whether you write them before or after the subcommand name. Previously, only `figma connect publish -v` toggled verbose mode; `figma connect -v publish` was silently ignored.
910

1011
### Compose

cli/npm_catalog.toml

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,20 @@
140140
"@testing-library/user-event" = "14.6.1"
141141
"c8" = "^11.0.0"
142142
"just-bash" = "^2.10.2"
143-
"@lexical/clipboard" = "0.21.0"
144-
"@lexical/code" = "0.21.0"
145-
"@lexical/devtools-core" = "0.21.0"
146-
"@lexical/headless" = "0.21.0"
147-
"@lexical/history" = "0.21.0"
148-
"@lexical/html" = "0.21.0"
149-
"@lexical/link" = "0.21.0"
150-
"@lexical/list" = "0.21.0"
151-
"@lexical/markdown" = "0.21.0"
152-
"@lexical/react" = "0.21.0"
153-
"@lexical/rich-text" = "0.21.0"
154-
"@lexical/selection" = "0.21.0"
155-
"@lexical/utils" = "0.21.0"
156-
"lexical" = "0.21.0"
143+
"@lexical/clipboard" = "0.31.2"
144+
"@lexical/code" = "0.31.2"
145+
"@lexical/devtools-core" = "0.31.2"
146+
"@lexical/headless" = "0.31.2"
147+
"@lexical/history" = "0.31.2"
148+
"@lexical/html" = "0.31.2"
149+
"@lexical/link" = "0.31.2"
150+
"@lexical/list" = "0.31.2"
151+
"@lexical/markdown" = "0.31.2"
152+
"@lexical/react" = "0.31.2"
153+
"@lexical/rich-text" = "0.31.2"
154+
"@lexical/selection" = "0.31.2"
155+
"@lexical/utils" = "0.31.2"
156+
"lexical" = "0.31.2"
157157
"@types/lodash" = "4.17.20"
158158
"@types/lodash-es" = "4.17.12"
159159
"lodash" = "4.18.1"

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@figma/code-connect",
3-
"version": "1.4.6",
3+
"version": "1.4.7",
44
"description": "A tool for connecting your design system components in code with your design system in Figma",
55
"keywords": [],
66
"author": "Figma",
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { logger } from '../../common/logging'
2+
3+
jest.mock('../../common/fetch', () => ({
4+
request: { post: jest.fn() },
5+
isFetchError: (e: any) => !!e?.__isFetchError,
6+
}))
7+
8+
import { request as mockedRequest } from '../../common/fetch'
9+
import { upload } from '../upload'
10+
11+
const post = (mockedRequest as any).post as jest.Mock
12+
13+
function makeFetchError(status: number, retryAfter?: string) {
14+
const headerMap = new Map<string, string>()
15+
if (retryAfter) headerMap.set('Retry-After', retryAfter)
16+
return {
17+
__isFetchError: true,
18+
response: {
19+
status,
20+
headers: { get: (k: string) => headerMap.get(k) ?? null },
21+
},
22+
data: { message: 'err' },
23+
}
24+
}
25+
26+
function makeOkResponse() {
27+
return {
28+
data: {
29+
meta: {
30+
success: true,
31+
published_count: 0,
32+
failed_count: 0,
33+
published_nodes: [],
34+
failed_nodes: [],
35+
},
36+
},
37+
}
38+
}
39+
40+
function makeDocs(n: number) {
41+
return Array.from({ length: n }, (_, i) => ({
42+
figmaNode: `https://www.figma.com/design/abc123/Test?node-id=${i + 1}-0`,
43+
label: `L${i + 1}`,
44+
component: `C${i + 1}`,
45+
})) as any[]
46+
}
47+
48+
describe('upload — retry behavior (via postWithRetry)', () => {
49+
let recordedDelaysMs: number[]
50+
let originalSetTimeout: typeof setTimeout
51+
let exitSpy: jest.SpyInstance
52+
let stderrSpy: jest.SpyInstance
53+
54+
beforeEach(() => {
55+
post.mockReset()
56+
recordedDelaysMs = []
57+
originalSetTimeout = global.setTimeout
58+
59+
global.setTimeout = ((fn: any, ms: number) => {
60+
recordedDelaysMs.push(ms)
61+
fn()
62+
return 0 as any
63+
}) as any
64+
65+
exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
66+
throw new Error(`process.exit(${code})`)
67+
}) as any)
68+
69+
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
70+
71+
logger.warn = jest.fn()
72+
logger.info = jest.fn()
73+
logger.debug = jest.fn()
74+
logger.error = jest.fn()
75+
})
76+
77+
afterEach(() => {
78+
global.setTimeout = originalSetTimeout
79+
exitSpy.mockRestore()
80+
stderrSpy.mockRestore()
81+
jest.restoreAllMocks()
82+
})
83+
84+
describe('on 429 (rate limit)', () => {
85+
it('uses the extended schedule 5/15/30/45/60/75s and ignores Retry-After', async () => {
86+
// Even with a Retry-After header, the 429 path should use the local schedule.
87+
post
88+
.mockRejectedValueOnce(makeFetchError(429, '999'))
89+
.mockRejectedValueOnce(makeFetchError(429))
90+
.mockResolvedValueOnce(makeOkResponse())
91+
92+
await upload({
93+
accessToken: 'tok',
94+
docs: makeDocs(1),
95+
verbose: false,
96+
})
97+
98+
expect(post).toHaveBeenCalledTimes(3)
99+
expect(recordedDelaysMs).toEqual([5_000, 15_000])
100+
})
101+
102+
it('gives up after 6 retries (7 attempts total) and exits', async () => {
103+
for (let i = 0; i < 7; i++) post.mockRejectedValueOnce(makeFetchError(429))
104+
105+
await expect(
106+
upload({
107+
accessToken: 'tok',
108+
docs: makeDocs(1),
109+
verbose: false,
110+
}),
111+
).rejects.toThrow('process.exit(1)')
112+
113+
expect(post).toHaveBeenCalledTimes(7)
114+
expect(recordedDelaysMs).toEqual([5_000, 15_000, 30_000, 45_000, 60_000, 75_000])
115+
})
116+
})
117+
118+
describe('on 5xx (server error)', () => {
119+
it('uses the short schedule 5/15/30s', async () => {
120+
post
121+
.mockRejectedValueOnce(makeFetchError(500))
122+
.mockRejectedValueOnce(makeFetchError(503))
123+
.mockResolvedValueOnce(makeOkResponse())
124+
125+
await upload({
126+
accessToken: 'tok',
127+
docs: makeDocs(1),
128+
verbose: false,
129+
})
130+
131+
expect(post).toHaveBeenCalledTimes(3)
132+
expect(recordedDelaysMs).toEqual([5_000, 15_000])
133+
})
134+
135+
it('honors Retry-After header instead of the fixed delay', async () => {
136+
post.mockRejectedValueOnce(makeFetchError(503, '7')).mockResolvedValueOnce(makeOkResponse())
137+
138+
await upload({
139+
accessToken: 'tok',
140+
docs: makeDocs(1),
141+
verbose: false,
142+
})
143+
144+
expect(recordedDelaysMs).toEqual([7_000])
145+
})
146+
147+
it('gives up after 3 retries (4 attempts total) and exits', async () => {
148+
for (let i = 0; i < 4; i++) post.mockRejectedValueOnce(makeFetchError(500))
149+
150+
await expect(
151+
upload({
152+
accessToken: 'tok',
153+
docs: makeDocs(1),
154+
verbose: false,
155+
}),
156+
).rejects.toThrow('process.exit(1)')
157+
158+
expect(post).toHaveBeenCalledTimes(4)
159+
expect(recordedDelaysMs).toEqual([5_000, 15_000, 30_000])
160+
})
161+
})
162+
163+
it('tracks 429 and 5xx retry counters independently', async () => {
164+
// Alternate: 500, 429, 500, 429, 500, 429, success
165+
// After this sequence: 3 5xx retries used, 3 429 retries used → both still
166+
// within budget, request succeeds.
167+
post
168+
.mockRejectedValueOnce(makeFetchError(500))
169+
.mockRejectedValueOnce(makeFetchError(429))
170+
.mockRejectedValueOnce(makeFetchError(500))
171+
.mockRejectedValueOnce(makeFetchError(429))
172+
.mockRejectedValueOnce(makeFetchError(500))
173+
.mockRejectedValueOnce(makeFetchError(429))
174+
.mockResolvedValueOnce(makeOkResponse())
175+
176+
await upload({
177+
accessToken: 'tok',
178+
docs: makeDocs(1),
179+
verbose: false,
180+
})
181+
182+
expect(post).toHaveBeenCalledTimes(7)
183+
// 500s consume 5xx schedule indices 0,1,2 → 5s,15s,30s
184+
// 429s consume 429 schedule indices 0,1,2 → 5s,15s,30s
185+
// Interleaved in encounter order:
186+
expect(recordedDelaysMs).toEqual([5_000, 5_000, 15_000, 15_000, 30_000, 30_000])
187+
})
188+
189+
it('does not retry on a non-retryable status (e.g. 400)', async () => {
190+
post.mockRejectedValueOnce(makeFetchError(400))
191+
192+
await expect(
193+
upload({
194+
accessToken: 'tok',
195+
docs: makeDocs(1),
196+
verbose: false,
197+
}),
198+
).rejects.toThrow('process.exit(1)')
199+
200+
expect(post).toHaveBeenCalledTimes(1)
201+
expect(recordedDelaysMs).toEqual([])
202+
})
203+
})
204+
205+
describe('upload — batch concurrency', () => {
206+
let stderrSpy: jest.SpyInstance
207+
208+
beforeEach(() => {
209+
post.mockReset()
210+
logger.warn = jest.fn()
211+
logger.info = jest.fn()
212+
logger.debug = jest.fn()
213+
logger.error = jest.fn()
214+
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
215+
})
216+
217+
afterEach(() => {
218+
stderrSpy.mockRestore()
219+
})
220+
221+
it('processes batches sequentially (one in flight at a time), not in parallel', async () => {
222+
let inFlight = 0
223+
let maxInFlight = 0
224+
const callOrder: number[] = []
225+
let callIndex = 0
226+
227+
post.mockImplementation(async () => {
228+
const id = ++callIndex
229+
callOrder.push(id)
230+
inFlight++
231+
maxInFlight = Math.max(maxInFlight, inFlight)
232+
await new Promise((r) => setImmediate(r))
233+
await new Promise((r) => setImmediate(r))
234+
inFlight--
235+
return makeOkResponse()
236+
})
237+
238+
await upload({
239+
accessToken: 'tok',
240+
docs: makeDocs(4),
241+
batchSize: 1,
242+
verbose: false,
243+
})
244+
245+
expect(post).toHaveBeenCalledTimes(4)
246+
expect(maxInFlight).toBe(1)
247+
expect(callOrder).toEqual([1, 2, 3, 4])
248+
})
249+
})

cli/src/connect/upload.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,52 @@ import { isFetchError, request } from '../common/fetch'
77

88
const COMPONENT_BROWSER_CONFLICT_REASON = 'Code Connect UI mapping already exists'
99

10-
const RETRY_DELAYS_MS = [5_000, 15_000, 30_000]
10+
// 429 (rate limit): extended schedule, ignore Retry-After to keep waits predictable.
11+
const RETRY_DELAYS_MS_429 = [5_000, 15_000, 30_000, 45_000, 60_000, 75_000]
12+
// 5xx (server error): original shorter schedule, still honoring Retry-After if provided.
13+
const RETRY_DELAYS_MS_5XX = [5_000, 15_000, 30_000]
1114

1215
async function postWithRetry<T>(
1316
apiUrl: string,
1417
batch: CodeConnectJSON[],
1518
accessToken: string,
1619
useOAuth = false,
1720
): Promise<{ data: T }> {
18-
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
21+
let attempt429 = 0
22+
let attempt5xx = 0
23+
// eslint-disable-next-line no-constant-condition
24+
while (true) {
1925
try {
2026
return await request.post<T>(apiUrl, batch, { headers: getHeaders(accessToken, useOAuth) })
2127
} catch (err) {
22-
if (isFetchError(err) && (err.response.status === 429 || err.response.status >= 500)) {
23-
if (attempt === RETRY_DELAYS_MS.length) {
24-
throw err
25-
}
28+
if (!isFetchError(err)) throw err
29+
30+
const status = err.response.status
31+
32+
if (status === 429) {
33+
if (attempt429 === RETRY_DELAYS_MS_429.length) throw err
34+
const delayMs = RETRY_DELAYS_MS_429[attempt429]
35+
logger.warn(
36+
`Received 429, retrying in ${delayMs / 1000}s (attempt ${attempt429 + 1}/${RETRY_DELAYS_MS_429.length})...`,
37+
)
38+
await new Promise((resolve) => setTimeout(resolve, delayMs))
39+
attempt429++
40+
} else if (status >= 500) {
41+
if (attempt5xx === RETRY_DELAYS_MS_5XX.length) throw err
2642
const retryAfterSec = err.response.headers.get('Retry-After')
2743
const delayMs = retryAfterSec
2844
? parseInt(retryAfterSec, 10) * 1_000
29-
: RETRY_DELAYS_MS[attempt]
45+
: RETRY_DELAYS_MS_5XX[attempt5xx]
3046
logger.warn(
31-
`Received ${err.response.status}, retrying in ${delayMs / 1000}s (attempt ${attempt + 1}/${RETRY_DELAYS_MS.length})...`,
47+
`Received ${status}, retrying in ${delayMs / 1000}s (attempt ${attempt5xx + 1}/${RETRY_DELAYS_MS_5XX.length})...`,
3248
)
3349
await new Promise((resolve) => setTimeout(resolve, delayMs))
50+
attempt5xx++
3451
} else {
3552
throw err
3653
}
3754
}
3855
}
39-
throw new Error('Unreachable')
4056
}
4157

4258
interface Args {

0 commit comments

Comments
 (0)