fix(core): name missing figma scope in 403, retry 429 with backoff

Two bugs from live figma-integration use:

1. `tokens` styles fallback 403s on non-Enterprise. /v1/files/:key/styles
   needs library_content:read — a scope the setup docs and the generic
   FORBIDDEN message both omitted, so the user saw "missing a read scope"
   with no way to know which. Each endpoint now carries a scope hint; the
   403 names the exact scope (styles → library_content:read). Setup text and
   skill scope list updated to include Library content: Read-only.

2. `asset` (and every per-node component render) had no 429 handling — the
   message said "back off and retry" but the client didn't. Two imports in
   a row tripped the per-minute limit and hard-failed. get() now retries 429
   with exponential backoff, honoring Retry-After when present, before
   surfacing RATE_LIMITED after maxRetries (default 3). sleep is injectable
   so tests don't wait.

Batch multi-node asset syntax (the documented /v1/images comma-ids rate
workaround) is a separate enhancement — retry makes the reported failure
self-heal, including the many-node component path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 13:31:13 -07:00
co-authored by Claude Fable 5
parent 030fded71d
commit 1bb7688347
4 changed files with 150 additions and 25 deletions
+67 -2
View File
@@ -106,14 +106,18 @@ describe("variables", () => {
});
describe("error mapping", () => {
it("maps 429 to RATE_LIMITED and 401 to BAD_TOKEN", async () => {
it("maps 429 to RATE_LIMITED (after retries) and 401 to BAD_TOKEN", async () => {
const stub = fetchStub(() => jsonResponse(429, {}));
const c429 = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(429, {})).fetch,
fetch: stub.fetch,
sleep: () => Promise.resolve(),
});
await expect(c429.styles("F")).rejects.toThrowError(
expect.objectContaining({ code: "RATE_LIMITED" }),
);
// 1 initial + 3 retries = 4 attempts
expect(stub.calls).toHaveLength(4);
const c401 = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(401, {})).fetch,
@@ -123,6 +127,67 @@ describe("error mapping", () => {
);
});
it("retries 429 and succeeds when the limit clears", async () => {
let n = 0;
const waits: number[] = [];
const client = createFigmaClient({
token: "t",
fetch: (() => {
n += 1;
return Promise.resolve(
n < 3
? jsonResponse(429, {})
: jsonResponse(200, {
meta: { styles: [{ key: "k", name: "P", style_type: "FILL" }] },
}),
);
}) as FigmaFetch,
sleep: (ms) => {
waits.push(ms);
return Promise.resolve();
},
});
const styles = await client.styles("F");
expect(styles[0]?.key).toBe("k");
expect(n).toBe(3); // two 429s then success
expect(waits).toEqual([1000, 2000]); // exponential backoff
});
it("honors Retry-After (seconds) over the backoff default", async () => {
let n = 0;
const waits: number[] = [];
const client = createFigmaClient({
token: "t",
fetch: (() => {
n += 1;
return Promise.resolve(
n === 1
? new Response("{}", { status: 429, headers: { "retry-after": "5" } })
: jsonResponse(200, { meta: { styles: [] } }),
);
}) as FigmaFetch,
sleep: (ms) => {
waits.push(ms);
return Promise.resolve();
},
});
await client.styles("F");
expect(waits).toEqual([5000]);
});
it("names the library_content scope in the styles 403 message", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch,
});
await expect(client.styles("F")).rejects.toThrowError(
expect.objectContaining({
code: "FORBIDDEN",
message: expect.stringContaining("library_content:read"),
}),
);
});
it("wraps other failures as HTTP_ERROR with status", async () => {
const client = createFigmaClient({
token: "t",
+80 -20
View File
@@ -89,6 +89,32 @@ export interface FigmaClientOptions {
token: string;
fetch?: FigmaFetch;
baseUrl?: string;
/** Injectable delay for 429 backoff — tests pass a no-op so retries don't
* actually wait. Defaults to a real timer. */
sleep?: (ms: number) => Promise<void>;
/** Max 429 retries before giving up. Default 3. */
maxRetries?: number;
}
/** Read scope each endpoint needs, named exactly as figma's PAT settings UI
* lists them — so a 403 tells the user which checkbox to tick, not just
* "some read scope". The styles endpoint's `library_content:read` is the one
* the setup docs used to omit (it 403s even with file content + metadata). */
const SCOPE_HINTS = {
fileContent: "File content: Read-only",
fileMetadata: "File metadata: Read-only",
libraryContent: "Library content: Read-only (library_content:read)",
} as const;
/** Parse a Retry-After header (figma sends integer seconds; the HTTP spec
* also allows a date) into ms, or null when absent/unparseable. */
function retryAfterMs(res: Response): number | null {
const raw = res.headers.get("retry-after");
if (raw === null) return null;
const secs = Number(raw);
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
const date = Date.parse(raw);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
function requireNodeId(ref: FigmaRef): string {
@@ -125,6 +151,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
" 1. figma.com/settings → Security → Personal access tokens → Generate new token",
" 2. Scopes (read-only is all this integration ever needs — it never writes to figma):",
" File content: Read-only · File metadata: Read-only",
" Library content: Read-only (needed for the `tokens` published-styles fallback)",
" Variables: Read-only (optional — brand variables, requires figma Enterprise;",
" without it `tokens` falls back to published styles)",
' 3. export FIGMA_TOKEN="figd_…" — add it to your shell profile or the project .env',
@@ -135,41 +162,66 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
}
const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init));
const base = options.baseUrl ?? "https://api.figma.com";
const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
const maxRetries = options.maxRetries ?? 3;
async function get(path: string, enterpriseGated = false): Promise<unknown> {
const res = await doFetch(`${base}${path}`, {
headers: { "X-Figma-Token": token },
});
interface GetOptions {
/** 403 → REQUIRES_ENTERPRISE (variables) rather than FORBIDDEN. */
enterpriseGated?: boolean;
/** scope named in a FORBIDDEN message so the user knows which to add. */
scopeHint?: string;
}
/** Throw the typed error for a non-ok response (no-op when res.ok). */
function throwForStatus(res: Response, path: string, opts: GetOptions): void {
if (res.ok) return;
if (res.status === 401)
throw new FigmaClientError(
"BAD_TOKEN",
"figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
401,
);
if (res.status === 403 && enterpriseGated)
if (res.status === 403 && opts.enterpriseGated)
throw new FigmaClientError(
"REQUIRES_ENTERPRISE",
"figma variables require an Enterprise plan (403) — fall back to styles",
403,
);
if (res.status === 403)
if (res.status === 403) {
const scopeLine = opts.scopeHint
? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.`
: "The token is missing a read scope, or your account can't view this file. Check File content: Read-only + File metadata: Read-only at figma.com/settings → Security.";
throw new FigmaClientError(
"FORBIDDEN",
"figma denied access (403) — the token is missing a read scope, or your account can't view this file. Check the token has File content: Read-only + File metadata: Read-only (figma.com/settings → Security) and that the file is visible to your account.",
`figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`,
403,
);
}
if (res.status === 429)
throw new FigmaClientError(
"RATE_LIMITED",
"figma rate limit hit (429) — back off and retry",
`figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`,
429,
);
if (!res.ok)
throw new FigmaClientError(
"HTTP_ERROR",
`figma request failed: HTTP ${res.status} ${path}`,
res.status,
);
throw new FigmaClientError(
"HTTP_ERROR",
`figma request failed: HTTP ${res.status} ${path}`,
res.status,
);
}
async function get(path: string, opts: GetOptions = {}): Promise<unknown> {
// Retry 429 with backoff before surfacing RATE_LIMITED — figma's limit is
// per-minute, so a couple of imports in quick succession hit it and a
// short wait clears it. Honor Retry-After when present, else exponential.
let res: Response;
for (let attempt = 0; ; attempt += 1) {
res = await doFetch(`${base}${path}`, { headers: { "X-Figma-Token": token } });
if (res.status !== 429 || attempt >= maxRetries) break;
const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt;
await sleep(wait);
}
throwForStatus(res, path, opts);
return res.json();
}
@@ -178,7 +230,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
const nodeId = requireNodeId(ref);
const params = new URLSearchParams({ ids: nodeId, format: opts.format });
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
const body = await get(`/v1/images/${ref.fileKey}?${params}`);
const body = await get(`/v1/images/${ref.fileKey}?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
});
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
const url = images[nodeId];
if (typeof url !== "string" || url === "")
@@ -190,7 +244,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},
async imageFills(fileKey) {
const body = await get(`/v1/files/${fileKey}/images`);
const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent });
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const images = isRecord(meta.images) ? meta.images : {};
const out = new Map<string, string>();
@@ -201,7 +255,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},
async variables(fileKey) {
const body = await get(`/v1/files/${fileKey}/variables/local`, true);
const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true });
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const variables = isRecord(meta.variables) ? meta.variables : {};
const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {};
@@ -214,7 +268,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},
async styles(fileKey) {
const body = await get(`/v1/files/${fileKey}/styles`);
const body = await get(`/v1/files/${fileKey}/styles`, {
scopeHint: SCOPE_HINTS.libraryContent,
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const styles = Array.isArray(meta.styles) ? meta.styles : [];
return styles.filter(
@@ -229,7 +285,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
async nodeTree(ref) {
const nodeId = requireNodeId(ref);
const params = new URLSearchParams({ ids: nodeId, geometry: "paths" });
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`);
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
});
const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {};
const entry = nodes[nodeId];
const doc = isRecord(entry) ? entry.document : undefined;
@@ -244,7 +302,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},
async fileVersion(fileKey) {
const body = await get(`/v1/files/${fileKey}?depth=1`);
const body = await get(`/v1/files/${fileKey}?depth=1`, {
scopeHint: SCOPE_HINTS.fileMetadata,
});
const version = isRecord(body) && typeof body.version === "string" ? body.version : "";
const lastModified =
isRecord(body) && typeof body.lastModified === "string" ? body.lastModified : "";
+1 -1
View File
@@ -10,7 +10,7 @@
"files": 18
},
"figma": {
"hash": "c2929c6cc7ca35b3",
"hash": "903f643bffefa5ce",
"files": 2
},
"general-video": {
+2 -2
View File
@@ -24,14 +24,14 @@ REST is used wherever it can be (usable at volume, headless); MCP only where Fig
**Preflight — before the first CLI call, check a token exists**: shell env (`[ -n "$FIGMA_TOKEN" ]`) **or** the project `.env` (the CLI auto-loads it — a `.env` entry counts as configured). If neither, do NOT run the command to harvest the error — walk the user through the one-time setup first, then stop and wait:
1. figma.com/settings → **Security****Personal access tokens** → Generate new token.
2. Scopes — read-only is all this integration ever needs (it never writes to Figma): **File content: Read-only** + **File metadata: Read-only**. Optionally **Variables: Read-only** for brand variables — that scope only works on Figma Enterprise; without it `tokens` degrades to published styles automatically (expected behavior, not an error — say so).
2. Scopes — read-only is all this integration ever needs (it never writes to Figma): **File content: Read-only** + **File metadata: Read-only**. Add **Library content: Read-only** if you'll run `tokens` on a non-Enterprise plan — the published-styles fallback hits `/v1/files/:key/styles`, which 403s without it (a scope the older setup text omitted). Optionally **Variables: Read-only** for brand variables — Enterprise-only; without it `tokens` degrades to published styles automatically (expected, not an error — say so). A 403 now names the exact missing scope; 429s retry automatically (per-minute limit, honors `Retry-After`).
3. `export FIGMA_TOKEN="figd_…"` — and suggest persisting it (shell profile or project `.env`) so no future session repeats this.
While onboarding, also set expectations in one breath: every import lands as a **local frozen file with recorded provenance** — renders never call Figma, re-running a command re-imports only what changed in Figma, and one token works for assets, brand tokens, and components across every file their Figma account can view.
- **Phases 45 (motion/shaders):** the Figma MCP connector (one-click OAuth), a separate credential from the token. If MCP tools error unauthenticated, tell the user to connect the Figma connector and stop.
- Say exactly which credential a failing phase needs — never present the split as broken.
- `BAD_TOKEN` (401) mid-flow → the token is expired/revoked; re-mint. `FORBIDDEN` (403) → missing read scope or no access to that file — check scopes + file visibility. `REQUIRES_ENTERPRISE` (403 on variables) → not a failure: styles fallback already ran.
- `BAD_TOKEN` (401) mid-flow → the token is expired/revoked; re-mint. `FORBIDDEN` (403) → the message names the exact missing scope (e.g. `library_content:read` for the styles fallback) — add it, or the file isn't visible to the account. `REQUIRES_ENTERPRISE` (403 on variables) → not a failure: styles fallback already ran. `RATE_LIMITED` (429) → the client already retried with backoff; if it still surfaces, wait a minute or import fewer nodes per call.
**Rate-limit awareness (spec §2.1):** MCP on a Starter plan is 6 tool calls/**month** (figma plan matrix as of 2026-07 — re-verify if quotas look off) — batch with `recursive:true` on the parent node, skip verification screenshots unless asked, and cache raw MCP responses so re-derivation never spends a second call. REST is per-minute (10+/min, per-endpoint buckets) — fine at volume, back off on 429.