Skip to content

Commit 6f30894

Browse files
committed
fix: handle Figma 429 rate limits with visible backoff and a clear error
Previously a rate-limited request retried silently -- the sync modal sat on 'Finding exportable assets…' with no feedback while the code slept, and exhausted retries surfaced as a generic API error. During downloads, every queued item would have retried and failed independently. - figmaFetch now honors Retry-After (capped at 120s), surfaces a per-second countdown in the progress modal via a provider status channel, and throws a typed SyncRateLimitError with a clear 'wait a minute and sync again' message when retries are exhausted. - The engine aborts the download queue on the first rate-limited item (the rest would only hit the same limit) and fails the sync with the rate-limit message. Nothing is lost: lastRemoteVersion is not advanced, and a later re-sync skips already-imported items by hash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kv5GTb2ddUqL46zSitS6Lf
1 parent b190011 commit 6f30894

6 files changed

Lines changed: 248 additions & 29 deletions

File tree

packages/root-cms/ui/components/AssetBrowser/AssetSyncModals.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -346,17 +346,26 @@ export function SyncProgressModal(props: {
346346
<div className="AssetBrowser__syncProgress__running">
347347
<Loader color="gray" size="sm" />
348348
<div className="AssetBrowser__syncProgress__phase">
349-
{progress.phase === 'enumerating' && 'Finding exportable assets…'}
350-
{progress.phase === 'downloading' &&
351-
(progress.total
352-
? `Importing ${Math.min(
353-
(progress.completed ?? 0) + 1,
354-
progress.total
355-
)} of ${progress.total}${
356-
progress.currentName ? ` — ${progress.currentName}` : ''
357-
}`
358-
: 'Importing…')}
359-
{progress.phase === 'finalizing' && 'Finishing up…'}
349+
{progress.note ? (
350+
progress.note
351+
) : (
352+
<>
353+
{progress.phase === 'enumerating' &&
354+
'Finding exportable assets…'}
355+
{progress.phase === 'downloading' &&
356+
(progress.total
357+
? `Importing ${Math.min(
358+
(progress.completed ?? 0) + 1,
359+
progress.total
360+
)} of ${progress.total}${
361+
progress.currentName
362+
? ` — ${progress.currentName}`
363+
: ''
364+
}`
365+
: 'Importing…')}
366+
{progress.phase === 'finalizing' && 'Finishing up…'}
367+
</>
368+
)}
360369
</div>
361370
</div>
362371
)}

packages/root-cms/ui/utils/asset-sync/engine.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
RemoteAsset,
1212
SyncAuthContext,
1313
SyncInProgressError,
14+
SyncRateLimitError,
1415
} from './types.js';
1516

1617
/** Builds a fake Timestamp-ish value. */
@@ -395,6 +396,33 @@ describe('syncFolder', () => {
395396
);
396397
});
397398

399+
it('aborts the download queue on a rate limit and rethrows', async () => {
400+
const folder = makeFolder();
401+
const {provider} = makeProvider({
402+
version: 'v1',
403+
remote: [
404+
{remoteId: 'file:1:0', filename: 'a.png', contents: 'aaa'},
405+
{remoteId: 'file:2:0', filename: 'b.png', contents: 'bbb'},
406+
],
407+
});
408+
provider.download = async () => {
409+
throw new SyncRateLimitError('rate limited');
410+
};
411+
const deps = makeDeps({folder});
412+
await expect(
413+
syncFolder({folder, provider, auth: AUTH, deps, concurrency: 1})
414+
).rejects.toThrow(SyncRateLimitError);
415+
// The first rate-limited item aborts the queue -- the remaining items
416+
// are not attempted (each would just be rate-limited again), and no
417+
// partial writes happen for them.
418+
expect(deps.createAssetFile).not.toHaveBeenCalled();
419+
expect(deps.finalizeFolderSync).toHaveBeenCalledWith(
420+
'folder-icons',
421+
expect.objectContaining({ok: false, error: 'rate limited'}),
422+
{remoteVersion: undefined}
423+
);
424+
});
425+
398426
it('refuses to run while another sync holds a fresh lease', async () => {
399427
const folder = makeFolder({
400428
state: {status: 'syncing', startedAt: ts(), startedBy: 'b@example.com'},

packages/root-cms/ui/utils/asset-sync/engine.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {
5454
SyncAuthContext,
5555
SyncInProgressError,
5656
SyncProgress,
57+
SyncProviderContext,
58+
SyncRateLimitError,
5759
SyncSummary,
5860
} from './types.js';
5961

@@ -153,9 +155,22 @@ export async function syncFolder(
153155
throw new Error(`Unknown sync provider: ${sync.provider}`);
154156
}
155157
const auth = options.auth ?? createBrowserAuthContext(provider.id);
156-
const onProgress = options.onProgress || (() => {});
157158
const concurrency = options.concurrency || DEFAULT_CONCURRENCY;
158159

160+
// Progress reporting. Providers report transient status (e.g. rate-limit
161+
// backoff countdowns) through `providerCtx.onStatus`, which re-emits the
162+
// latest progress with a `note`; regular progress events clear the note.
163+
let lastProgress: SyncProgress = {phase: 'enumerating'};
164+
const onProgress = (progress: SyncProgress) => {
165+
lastProgress = progress;
166+
options.onProgress?.(progress);
167+
};
168+
const providerCtx: SyncProviderContext = {
169+
onStatus: (message: string) => {
170+
options.onProgress?.({...lastProgress, note: message});
171+
},
172+
};
173+
159174
// Resolve the token before taking the lease or writing anything, so a
160175
// missing token surfaces as a prompt with zero side effects.
161176
await auth.getToken();
@@ -189,7 +204,7 @@ export async function syncFolder(
189204

190205
try {
191206
onProgress({phase: 'enumerating'});
192-
const remoteList = await provider.listRemoteAssets(sync, auth);
207+
const remoteList = await provider.listRemoteAssets(sync, auth, providerCtx);
193208
remoteVersion = remoteList.version;
194209

195210
const folderPath = joinFolderPath(folder.parent, folder.name);
@@ -252,7 +267,7 @@ export async function syncFolder(
252267
// Let the provider batch per-item download prep (e.g. Figma render URL
253268
// resolution) for just the items that actually need downloading.
254269
if (provider.prepareDownloads && candidates.length > 0) {
255-
await provider.prepareDownloads(candidates, sync, auth);
270+
await provider.prepareDownloads(candidates, sync, auth, providerCtx);
256271
}
257272

258273
// Pre-assign de-duped names for new imports (deterministic by remote id
@@ -275,7 +290,16 @@ export async function syncFolder(
275290
let completed = 0;
276291
onProgress({phase: 'downloading', total: candidates.length, completed});
277292

293+
// A rate-limited item means every remaining item would also be
294+
// rate-limited (after its own long retries), so the first one aborts
295+
// the queue. Nothing is lost: re-syncing later resumes cheaply since
296+
// already-imported items are skipped by content hash.
297+
let rateLimitError: SyncRateLimitError | null = null;
298+
278299
await runPool(candidates, concurrency, async (remoteAsset) => {
300+
if (rateLimitError) {
301+
return;
302+
}
279303
onProgress({
280304
phase: 'downloading',
281305
total: candidates.length,
@@ -285,6 +309,10 @@ export async function syncFolder(
285309
try {
286310
await syncItem(remoteAsset);
287311
} catch (err: any) {
312+
if (err instanceof SyncRateLimitError) {
313+
rateLimitError = err;
314+
return;
315+
}
288316
console.error(`failed to sync "${remoteAsset.name}":`, err);
289317
summary.failed.push({
290318
name: remoteAsset.name,
@@ -295,9 +323,18 @@ export async function syncFolder(
295323
onProgress({phase: 'downloading', total: candidates.length, completed});
296324
});
297325

326+
if (rateLimitError) {
327+
throw rateLimitError;
328+
}
329+
298330
async function syncItem(remoteAsset: RemoteAsset) {
299331
const existing = syncedByRemoteId.get(remoteAsset.remoteId);
300-
const file = await provider!.download(remoteAsset, sync!, auth);
332+
const file = await provider!.download(
333+
remoteAsset,
334+
sync!,
335+
auth,
336+
providerCtx
337+
);
301338
const contentHash = await deps.sha1(file);
302339
// Unchanged bytes: strict no-op (no upload, no db write, no fan-out).
303340
if (existing && existing.source?.contentHash === contentHash) {

packages/root-cms/ui/utils/asset-sync/figma.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {afterEach, describe, expect, it, vi} from 'vitest';
22
import {FIGMA_PROVIDER, buildExportFilename, parseFigmaUrl} from './figma.js';
3+
import {SyncAuthContext, SyncRateLimitError} from './types.js';
34

45
describe('parseFigmaUrl', () => {
56
it('parses design URLs', () => {
@@ -157,6 +158,84 @@ describe('FIGMA_PROVIDER.validateToken', () => {
157158
});
158159
});
159160

161+
describe('FIGMA_PROVIDER rate limiting', () => {
162+
const SOURCE = {
163+
provider: 'figma',
164+
url: 'https://www.figma.com/design/AbC123/File',
165+
figma: {fileKey: 'AbC123'},
166+
};
167+
const AUTH: SyncAuthContext = {
168+
getToken: async () => 'tok',
169+
invalidateToken: () => {},
170+
};
171+
172+
afterEach(() => {
173+
vi.unstubAllGlobals();
174+
vi.useRealTimers();
175+
});
176+
177+
it('retries a rate-limited request and reports a countdown', async () => {
178+
vi.useFakeTimers();
179+
let calls = 0;
180+
vi.stubGlobal(
181+
'fetch',
182+
vi.fn(async () => {
183+
calls += 1;
184+
if (calls === 1) {
185+
return {
186+
ok: false,
187+
status: 429,
188+
headers: {get: () => '1'},
189+
json: async () => ({}),
190+
} as any;
191+
}
192+
return {
193+
ok: true,
194+
status: 200,
195+
headers: {get: () => null},
196+
json: async () => ({
197+
version: '9',
198+
document: {id: '0:0', name: 'root', children: []},
199+
}),
200+
} as any;
201+
})
202+
);
203+
const notes: string[] = [];
204+
const promise = FIGMA_PROVIDER.listRemoteAssets(SOURCE, AUTH, {
205+
onStatus: (message) => notes.push(message),
206+
});
207+
await vi.advanceTimersByTimeAsync(11_000);
208+
const result = await promise;
209+
expect(result.version).toEqual('9');
210+
expect(calls).toEqual(2);
211+
expect(notes.some((n) => n.includes('rate limit'))).toBe(true);
212+
});
213+
214+
it('throws SyncRateLimitError after exhausting retries', async () => {
215+
vi.useFakeTimers();
216+
vi.stubGlobal(
217+
'fetch',
218+
vi.fn(
219+
async () =>
220+
({
221+
ok: false,
222+
status: 429,
223+
headers: {get: () => null},
224+
json: async () => ({}),
225+
}) as any
226+
)
227+
);
228+
const promise = FIGMA_PROVIDER.listRemoteAssets(SOURCE, AUTH).catch(
229+
(err) => err
230+
);
231+
// Backoff floors are 10s + 20s + 30s before the final attempt.
232+
await vi.advanceTimersByTimeAsync(61_000);
233+
const err = await promise;
234+
expect(err).toBeInstanceOf(SyncRateLimitError);
235+
expect(String(err.message)).toContain('rate-limiting');
236+
});
237+
});
238+
160239
describe('FIGMA_PROVIDER.parseSourceUrl', () => {
161240
it('returns a source ref for figma URLs', () => {
162241
const url = 'https://www.figma.com/design/AbC123xyz/File?node-id=1-2';

packages/root-cms/ui/utils/asset-sync/figma.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
RemoteAssetList,
1818
SyncAccessError,
1919
SyncAuthContext,
20+
SyncProviderContext,
21+
SyncRateLimitError,
2022
SyncSourceRef,
2123
SyncTokenRequiredError,
2224
} from './types.js';
@@ -34,6 +36,9 @@ const IMAGES_BATCH_SIZE = 50;
3436
/** Max retries for rate-limited (429) API requests. */
3537
const MAX_RATE_LIMIT_RETRIES = 3;
3638

39+
/** Max seconds to wait out a single Retry-After before retrying. */
40+
const MAX_RETRY_WAIT_SECONDS = 120;
41+
3742
/** Formats supported by the Figma images API. */
3843
type FigmaExportFormat = 'png' | 'jpg' | 'svg' | 'pdf';
3944

@@ -116,19 +121,43 @@ export function parseFigmaUrl(url: string): FigmaSourceRef | null {
116121

117122
/**
118123
* Calls the Figma REST API, mapping auth failures to typed errors and
119-
* retrying rate-limited requests with backoff.
124+
* retrying rate-limited requests with backoff. Rate-limit waits honor the
125+
* `Retry-After` header and are surfaced to the user as a countdown via
126+
* `onStatus`; when retries are exhausted a {@link SyncRateLimitError} is
127+
* thrown so the sync fails with a clear "try again in a bit" message
128+
* instead of hanging silently.
120129
*/
121-
async function figmaFetch(path: string, token: string): Promise<any> {
130+
async function figmaFetch(
131+
path: string,
132+
token: string,
133+
onStatus?: (message: string) => void
134+
): Promise<any> {
122135
let attempt = 0;
123136
for (;;) {
124137
const res = await fetch(`${FIGMA_API_ORIGIN}${path}`, {
125138
headers: {'X-Figma-Token': token},
126139
});
127-
if (res.status === 429 && attempt < MAX_RATE_LIMIT_RETRIES) {
140+
if (res.status === 429) {
128141
attempt += 1;
129-
const retryAfter = Number(res.headers.get('retry-after')) || 0;
130-
const delaySeconds = Math.min(Math.max(retryAfter, 5 * attempt), 60);
131-
await sleep(delaySeconds * 1000);
142+
const retryAfter = Number(res.headers?.get?.('retry-after')) || 0;
143+
if (attempt > MAX_RATE_LIMIT_RETRIES) {
144+
throw new SyncRateLimitError(
145+
'Figma is rate-limiting API requests for your account. Wait a minute or two, then sync again — the sync picks up where it left off.',
146+
retryAfter || undefined
147+
);
148+
}
149+
// Honor Retry-After (capped), with a growing floor when absent.
150+
const delaySeconds = Math.min(
151+
Math.max(retryAfter, 10 * attempt),
152+
MAX_RETRY_WAIT_SECONDS
153+
);
154+
for (let remaining = delaySeconds; remaining > 0; remaining--) {
155+
onStatus?.(
156+
`Figma rate limit reached — retrying in ${remaining}s… (attempt ${attempt} of ${MAX_RATE_LIMIT_RETRIES})`
157+
);
158+
await sleep(1000);
159+
}
160+
onStatus?.('Retrying…');
132161
continue;
133162
}
134163
if (res.ok) {
@@ -241,7 +270,8 @@ function collectExportableNodes(
241270

242271
async function listRemoteAssets(
243272
source: SyncSourceRef,
244-
auth: SyncAuthContext
273+
auth: SyncAuthContext,
274+
ctx?: SyncProviderContext
245275
): Promise<RemoteAssetList> {
246276
const figma = source.figma;
247277
if (!figma?.fileKey) {
@@ -254,7 +284,8 @@ async function listRemoteAssets(
254284
if (figma.nodeId) {
255285
const data = await figmaFetch(
256286
`/v1/files/${encodeURIComponent(figma.fileKey)}/nodes?ids=${encodeURIComponent(figma.nodeId)}`,
257-
token
287+
token,
288+
ctx?.onStatus
258289
);
259290
version = data?.version ? String(data.version) : undefined;
260291
const nodeData = data?.nodes?.[figma.nodeId];
@@ -267,7 +298,8 @@ async function listRemoteAssets(
267298
} else {
268299
const data = await figmaFetch(
269300
`/v1/files/${encodeURIComponent(figma.fileKey)}`,
270-
token
301+
token,
302+
ctx?.onStatus
271303
);
272304
version = data?.version ? String(data.version) : undefined;
273305
roots = data?.document ? [data.document] : [];
@@ -301,7 +333,8 @@ async function listRemoteAssets(
301333
async function prepareDownloads(
302334
assets: RemoteAsset[],
303335
source: SyncSourceRef,
304-
auth: SyncAuthContext
336+
auth: SyncAuthContext,
337+
ctx?: SyncProviderContext
305338
): Promise<void> {
306339
const fileKey = source.figma?.fileKey;
307340
if (!fileKey) {
@@ -334,7 +367,8 @@ async function prepareDownloads(
334367
}
335368
const data = await figmaFetch(
336369
`/v1/images/${encodeURIComponent(fileKey)}?${params.toString()}`,
337-
token
370+
token,
371+
ctx?.onStatus
338372
);
339373
if (data?.err) {
340374
throw new Error(`Figma image render failed: ${data.err}`);

0 commit comments

Comments
 (0)