mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Fable 5
parent
030fded71d
commit
1bb7688347
@@ -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",
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"files": 18
|
||||
},
|
||||
"figma": {
|
||||
"hash": "c2929c6cc7ca35b3",
|
||||
"hash": "903f643bffefa5ce",
|
||||
"files": 2
|
||||
},
|
||||
"general-video": {
|
||||
|
||||
@@ -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 4–5 (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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user