mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core,cli): address PR review — consistent 403 error shape, cap Retry-After, URL-safe ref split
Rames's inline findings on #2112: - forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws inside) so the caller's single throw covers all cases — no mixed throw/return contract for a future wrapping caller. - retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer silently blocks the CLI for an hour before RATE_LIMITED. - asset ref gathering extracted to gatherAssetRefs() and made URL-safe: bare fileKey:nodeId tokens comma-split, but a figma URL with commas in its query (multi-select node-id=1:2,3:4) is kept whole. - Documented in SKILL that 429 retry lives in the shared request path, so EVERY read endpoint retries (not just asset) — blast-radius note the reviewer asked for. variables intentionally still retries: its fallback is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate. Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs URL-vs-bare split. client 24, cli asset 11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1d18b30625
commit
87e2a70f9a
@@ -153,6 +153,47 @@ describe("error mapping", () => {
|
||||
expect(waits).toEqual([1000, 2000]); // exponential backoff
|
||||
});
|
||||
|
||||
it("caps an oversized Retry-After at 60s so the CLI can't block for an hour", 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": "3600" } })
|
||||
: jsonResponse(200, { meta: { styles: [] } }),
|
||||
);
|
||||
}) as FigmaFetch,
|
||||
sleep: (ms) => {
|
||||
waits.push(ms);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
await client.styles("F");
|
||||
expect(waits).toEqual([60_000]); // 3600s clamped, not 3_600_000
|
||||
});
|
||||
|
||||
it("retries 429 on non-styles endpoints too (retry lives in the shared get)", async () => {
|
||||
let n = 0;
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: (() => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n < 2
|
||||
? jsonResponse(429, {})
|
||||
: jsonResponse(200, { images: { "1:2": "https://cdn/a.png" } }),
|
||||
);
|
||||
}) as FigmaFetch,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
const out = await client.renderNodes("F", ["1:2"], { format: "png" });
|
||||
expect(out[0]?.url).toBe("https://cdn/a.png");
|
||||
expect(n).toBe(2); // one 429 then success
|
||||
});
|
||||
|
||||
it("honors Retry-After (seconds) over the backoff default", async () => {
|
||||
let n = 0;
|
||||
const waits: number[] = [];
|
||||
|
||||
@@ -122,15 +122,23 @@ const SCOPE_HINTS = {
|
||||
libraryContent: "Library content: Read-only (library_content:read)",
|
||||
} as const;
|
||||
|
||||
/** Longest we'll auto-wait on a single Retry-After before giving up — past a
|
||||
* minute the user is better off cancelling and reducing batch size (the
|
||||
* RATE_LIMITED message says so) than watching the CLI block silently. */
|
||||
const MAX_RETRY_WAIT_MS = 60_000;
|
||||
|
||||
/** Parse a Retry-After header (figma sends integer seconds; the HTTP spec
|
||||
* also allows a date) into ms, or null when absent/unparseable. */
|
||||
* also allows a date) into ms, capped at MAX_RETRY_WAIT_MS, or null when
|
||||
* absent/unparseable. The cap keeps a spec-legal `Retry-After: 3600` (tier
|
||||
* quota exhaustion) from silently blocking the CLI for an hour. */
|
||||
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);
|
||||
if (Number.isFinite(secs)) return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, secs * 1000));
|
||||
const date = Date.parse(raw);
|
||||
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
||||
if (Number.isNaN(date)) return null;
|
||||
return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, date - Date.now()));
|
||||
}
|
||||
|
||||
/** Figma's error bodies are precise — "Invalid token", or "Invalid scope(s):
|
||||
@@ -217,8 +225,11 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
* surfaced verbatim. Falls back to the endpoint's scopeHint when the body
|
||||
* is silent. */
|
||||
function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError {
|
||||
// Every branch RETURNS the error (the caller throws once) — no mixed
|
||||
// throw/return, so a future caller that wraps the result gets consistent
|
||||
// behavior across all three cases.
|
||||
if (body && /invalid token/i.test(body))
|
||||
throw new FigmaClientError(
|
||||
return new FigmaClientError(
|
||||
"BAD_TOKEN",
|
||||
"figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
|
||||
403,
|
||||
|
||||
Reference in New Issue
Block a user