fix(core): name missing figma scope in 403, retry 429 with backoff#2112
fix(core): name missing figma scope in 403, retry 429 with backoff#2112vanceingalls wants to merge 3 commits into
Conversation
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>
Extends the scope+retry work from the figma bug-bash (valid report:
9-bugs-with-repros; the skill-not-used report was discarded).
- 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad
PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing
scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN
with re-mint advice; a scope body surfaces figma's own diagnosis verbatim;
else falls back to the endpoint's scope hint. Reads both err and message
(variables endpoint uses message). One fix, honest messages for bugs 1/4/9.
- Batch asset fetch (requested): figma asset accepts multiple refs
(space-separated or comma-joined) of one file and renders them in a SINGLE
/v1/images call via new client.renderNodes — figma's documented per-minute
rate-limit workaround. runAssetImport delegates to runAssetImportMany;
cache-checks per node, batches only the misses, one index.md regen.
- NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling
the numbered setup list. Indent every line; single-line hints unchanged.
Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not
scope advice. Client suite 22, cli figma 33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Extended this PR with the rest of the valid bug-bash cluster (the second report interpreted its own findings against the raw MCP tools without the /figma skill — declared invalid, discarded; everything below traces to the CLI-driven report + the inline scope/retry/batch asks): 403-body parse (bug 4 + closes 1 & 9's messaging). Figma returns Batch asset fetch (requested). NO_TOKEN box (bug 8). errorBox indented only the first hint line, so the numbered setup list rendered ragged. Now indents every line; single-line hints unchanged (no impact on ~80 other callers). Deliberately not here: the mapper visual-fidelity cluster — IMAGE fills dropped to empty divs (bug 2, P0), rasterized-vector double-paint (bug 3), font affordance (bug 6) — is a separate subsystem ( Suites: core figma 114, cli figma 33, all green. |
miga-heygen
left a comment
There was a problem hiding this comment.
Review — fix(core): name missing figma scope in 403, retry 429 with backoff
Two real bugs fixed, plus a batch optimization that's the natural rate-limit workaround. Solid work.
Bug 1 — 403 scope naming
Clean design. SCOPE_HINTS gives each endpoint a named scope, readFigmaErrorMessage reads figma's own body (err and message fields), and forbiddenError classifies the 403 into BAD_TOKEN / REQUIRES_ENTERPRISE / FORBIDDEN-with-scope. The styles endpoint's library_content:read omission was a real trap — the docs said it wasn't needed, it was. Good that the NO_TOKEN setup text and the /figma skill's scope list both gained the line.
The forbiddenError reclassification of figma's "Invalid token" 403 as BAD_TOKEN is a nice catch — figma returns 403 (not 401) for bad PATs on file endpoints, so without this the user gets a confusing "missing scope" message when the real problem is a dead token.
Nit: forbiddenError mixes throw (for the "Invalid token" path) and return (for everything else). Both work — the caller already wraps the return in throw — but the inconsistency is confusing to read. Either all-throw or all-return would be cleaner.
Bug 2 — 429 retry
get() now retries 429 with exponential backoff (1s → 2s → 4s), honors Retry-After when present, and sleep is injectable for testing. retryAfterMs handles both integer-seconds and HTTP-date forms. Clean.
Test coverage is thorough: retry-then-succeed, Retry-After honored over the default, attempt count verified (1 initial + 3 retries = 4), RATE_LIMITED only after exhausting retries.
Batch renderNodes
Good refactoring. renderNode delegates to renderNodes via this (correct for test fakes that override renderNode). runAssetImport delegates to runAssetImportMany. The extracted helpers all carry new facts:
requireNodeRef— validation + error messagereuseExisting— cache-hit logic with metadata upsert (needed by both single and batch)freezeAndRecord— download + sanitize + freeze + record, deliberately omits index regeneration so the caller does it once
The batch path in runAssetImportMany is clean: resolve cache hits first, batch-render only the misses in one /v1/images call, fill in slots, regen index once. Per-node failures come back as url:null rather than aborting the batch — matches the renderNodes contract.
Cross-file batch rejection (all refs must share a fileKey) is correctly enforced with a clear error message.
SSOT check
- No duplicated decisions. Cache-hit logic has one owner (
reuseExisting), 403 classification has one owner (forbiddenError), retry has one owner (get()). - No stale concepts. The old inline status checks in
get()are fully replaced bythrowForStatus. The old inline/v1/imagescall inrenderNodeis replaced by delegation torenderNodes. safeRegenerateIndexis called exactly once per import path (end ofrunAssetImportMany), not N times per node. Good.
Tests
17/17 (up from 13). New tests cover retry-then-succeed, Retry-After honored, styles 403 scope naming, figma's own err field surfaced verbatim, "Invalid token" reclassified as BAD_TOKEN, REQUIRES_ENTERPRISE preserved, batch renderNodes (one call for many nodes, url:null for failed nodes), runAssetImportMany (batch import, cross-file rejection). All the new behavior is covered.
Verdict
LGTM. The forbiddenError throw/return inconsistency is a nit — take it or leave it.
— Review by Miga
CI typecheck caught the tokens.test mock missing the new renderNodes member on FigmaClient (asset/component mocks were updated, this one was missed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 4fc699f.
Solid bug-bash bundle — 403-body parsing, batch /v1/images, retry-with-backoff, and the multi-line NO_TOKEN indent are all well-motivated, and the test suite meaningfully covers the interesting shapes (Retry-After honored, "Invalid token" → BAD_TOKEN reclassification, batch → one call).
Blockers
- CI Typecheck is red.
packages/cli/src/commands/figma/tokens.test.ts:16-36builds aFigmaClient-typed fake withrenderNode / imageFills / variables / styles / nodeTree / fileVersion— but the newrenderNodesmethod wasn't added, so TS reports the literal isn't assignable toFigmaClient. Theasset.test.tsandcomponent.test.tsfakes both got the delegation shim;tokens.test.tsdidn't. Same one-liner fix — either addrenderNodes: () => Promise.reject(new Error("unused"))(tokens tests never render nodes), or thethis.renderNode-delegating stub used incomponent.test.tsfor symmetry.bun run --filter '@hyperframes/cli' typechecklocally to confirm before pushing.
Concerns
- Retry-loop scope widening (
client.ts:276): the retry is at theget()level, so all six read endpoints inherit it. Likely intentional, but blast-radius isn't obvious from PR title/body and only thestylespath has an explicit retry test. forbiddenErrorthrow/return asymmetry (client.ts:219): BAD_TOKEN branch throws; FORBIDDEN / REQUIRES_ENTERPRISE branches return and rely on the caller'sthrow. Works today, but fragile if wrapping ever gets added at the callsite.
Nits
retryAfterMsuncapped (client.ts:127): a spec-legal hugeRetry-Aftersilently blocks the CLI.- CLI positional comma-splitter is URL-unsafe (
asset.ts:277): a figma URL containing a literal,gets torn.
Questions
- Was
component.tsintentionally left alone (relying onrenderNode → renderNodesdelegation for the retry inheritance), or is there a follow-up to make it userenderNodesdirectly for batch rate-limit gains on many-node component imports? The PR body implies retry-only, which is what the delegation gives you — just checking. - Any reason
runAssetImportManyrejects cross-file batches with athrow new Error(...)instead of a typedFigmaClientError? Not a bug, but the surrounding module uses typed errors elsewhere.
What I didn't verify
- Live figma behavior of
Retry-After(only inferred from HTTP spec / figma docs). - Whether any other test file has a
FigmaClientfake that also needs the shim — I only inspected the failure the CI report named, but iftokens.test.tswas missed, worthrg 'FigmaClient' packagesbefore pushing to catch any sibling.
| * file endpoints), "Invalid scope(s) … requires X" is a missing scope | ||
| * surfaced verbatim. Falls back to the endpoint's scopeHint when the body | ||
| * is silent. */ | ||
| function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError { |
There was a problem hiding this comment.
🟠 forbiddenError has an asymmetric throw/return contract that will bite a future reader. The BAD_TOKEN branch throws inside the function; the REQUIRES_ENTERPRISE and FORBIDDEN branches return and rely on the caller's throw forbiddenError(...) at line 257 to actually throw. It works today because the caller unconditionally re-throws — but the function's return type says FigmaClientError, so a reader can't tell the two branches diverge without stepping through both call sites. If someone later adds a caller that does const e = forbiddenError(...) (e.g. wrapping / decorating the error), the BAD_TOKEN branch silently bypasses their wrapping and the FORBIDDEN branch doesn't. Pick one shape — either throw in every branch (return type becomes never) or return in every branch (caller does the throw once).
— Review by Rames D Jusso
| // 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) { |
There was a problem hiding this comment.
🟠 The retry loop lives inside get(), so this PR silently changes the 429 behavior of every read endpoint — imageFills, variables, styles, nodeTree, fileVersion — not just renderNodes. That's likely the right call (per-minute limits are per-endpoint but the client's callers all benefit), but the PR title / body scopes the fix to asset and per-node component renders, and the test suite only asserts retry on the styles path. Two suggestions: (a) mention in the PR body / SKILL.md that all reads retry now — the RATE_LIMITED line already says the client retries with backoff, but the widening isn't obvious to someone reading the diff to understand blast radius; (b) if any endpoint is intentionally meant to fail fast on 429 (e.g. variables because callers already have a styles fallback and a 3-retry wait delays the fallback by up to 7s), it needs an opt-out. Otherwise the current shape looks correct.
— Review by Rames D Jusso
|
|
||
| /** 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 { |
There was a problem hiding this comment.
🟡 retryAfterMs has no upper bound. If figma ever sends Retry-After: 3600 (spec-legal — it's the value they emit for tier-quota exhaustion on some paid-endpoint stacks), the CLI silently blocks for an hour before returning RATE_LIMITED, with no output. Same for a Retry-After date parsed to "1 hour from now." Consider capping at ~60s and using min(retryAfterMs, cap): past a minute the user's better off Ctrl-C'ing and either waiting explicitly or reducing batch size (which the message already suggests). Non-blocking, but a common footgun with header-driven waits.
— Review by Rames D Jusso
| const positionals = | ||
| Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref]; | ||
| const refs = positionals | ||
| .flatMap((r) => String(r).split(",")) |
There was a problem hiding this comment.
🟡 The comma-splitter is greedy: .flatMap((r) => String(r).split(",")) treats every literal , in argv as a delimiter, even inside a figma URL query. A user pasting hyperframes figma asset 'https://…/file/KEY?node-id=1%3A2,3%3A4' (figma sometimes uses comma-separated node-id= in shared multi-select URLs — verify against a real link, but it's a plausible copy-paste) would get the URL torn in half at the comma → parse errors on both halves. Two options: (a) only split when the token doesn't look like a URL (!/^https?:/i.test(r)); (b) require the user to be explicit and pass separate positional args when using URLs, then only split unquoted fileKey:nodeId positionals. Also worth a test where one positional is a URL — the current batch test uses raw KEY:1-2 refs, which don't exercise this path.
— Review by Rames D Jusso
Two bugs surfaced running the figma integration live.
Bug 1 —
tokensstyles fallback 403s on non-Enterprise with a useless messageThe docs promised the published-styles fallback works with just File content + File metadata scopes. It doesn't:
/v1/files/:key/stylesalso needslibrary_content:read. Worse, the generic 403 said "the token is missing a read scope" without naming which — a dead end for the user.Fix: every endpoint now passes a scope hint, and the FORBIDDEN message names the exact scope. The styles 403 now reads "This endpoint needs the "Library content: Read-only (library_content:read)" scope — add it at figma.com/settings…". The NO_TOKEN setup text and the
/figmaskill's scope list both gained the Library content line.Bug 2 —
asset(and per-node component renders) never retried 429The 429 message said "back off and retry" but the client didn't — two imports in a row tripped Figma's per-minute limit and hard-failed. Since a component import renders each rasterized node as a separate
/v1/imagescall, this hit the many-node path hardest.Fix:
get()retries 429 with exponential backoff (1s → 2s → 4s), honoringRetry-Afterwhen Figma sends it, before surfacing RATE_LIMITED aftermaxRetries(default 3).sleepis injectable so tests don't actually wait.Tests
client suite 17/17 (up from 13): retry-then-succeed, Retry-After honored over the default, RATE_LIMITED only after exhausting retries (asserts attempt count), and the
library_content:readscope named in the styles 403.Not in this PR
Batch multi-node
assetsyntax (the documented/v1/imagescomma-separated-ids rate workaround) is a real enhancement but a separate change — it needs command-UX + per-node cache/manifest wiring. The retry fix makes the reported failure self-heal without it.🤖 Generated with Claude Code