fix(core,cli): attribute figma REST failures to the endpoint that failed

cli_error had no way to tell which figma REST call (images, files_nodes,
variables_local, styles, ...) actually hit RATE_LIMITED/FORBIDDEN/etc, so
the dashboard could see failures spike but not which call caused them.

FigmaClientError now carries a low-cardinality endpoint label (never the
raw fileKey/nodeId), threaded through to cli_error's endpoint property.
This commit is contained in:
Vance Ingalls
2026-07-13 21:41:55 +00:00
parent 9b71bc2103
commit 4ca1552e20
5 changed files with 103 additions and 5 deletions
@@ -29,6 +29,7 @@ export async function withFigmaErrors(command: string, fn: () => Promise<void>):
stack_trace: err.stack,
command,
kind: "command_error",
endpoint: err instanceof FigmaClientError ? err.endpoint : undefined,
});
await telemetry.flush();
} catch {
+15
View File
@@ -431,6 +431,21 @@ describe("trackCliError", () => {
expect(props.error_message).toContain("[path]");
expect(props.stack_trace).not.toContain("/Users/alice");
});
it("forwards the figma endpoint label when supplied", () => {
trackCliError({
error_name: "RATE_LIMITED",
error_message: "figma rate limit hit (429)",
command: "figma asset",
kind: "command_error",
endpoint: "images",
});
expect(trackEvent).toHaveBeenCalledWith(
"cli_error",
expect.objectContaining({ endpoint: "images" }),
);
});
});
describe("trackCommandFailure", () => {
+4
View File
@@ -490,6 +490,9 @@ export function trackCliError(props: {
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
/** Low-cardinality figma REST call label (e.g. "images", "files_nodes") —
* which endpoint failed, for FigmaClientError-backed failures only. */
endpoint?: string;
}): void {
trackEvent("cli_error", {
error_name: props.error_name,
@@ -502,6 +505,7 @@ export function trackCliError(props: {
: undefined,
command: props.command,
kind: props.kind,
endpoint: props.endpoint,
});
}
+48
View File
@@ -271,6 +271,54 @@ describe("error mapping", () => {
});
});
describe("endpoint attribution", () => {
it("labels each call's error with a low-cardinality endpoint, never the raw fileKey/nodeId", async () => {
const client = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(500, {})).fetch,
});
const cases: Array<[string, () => Promise<unknown>]> = [
["images", () => client.renderNode({ fileKey: "SECRET", nodeId: "1:2" }, { format: "png" })],
["images", () => client.renderNodes("SECRET", ["1:2"], { format: "png" })],
["files_images", () => client.imageFills("SECRET")],
["variables_local", () => client.variables("SECRET")],
["styles", () => client.styles("SECRET")],
["files_nodes", () => client.nodeTree({ fileKey: "SECRET", nodeId: "1:2" })],
["file_meta", () => client.fileVersion("SECRET")],
];
for (const [expected, call] of cases) {
const err = await call().catch((e: unknown) => e);
expect(err).toBeInstanceOf(FigmaClientError);
if (err instanceof FigmaClientError) {
expect(err.endpoint).toBe(expected);
expect(err.endpoint).not.toContain("SECRET");
}
}
});
it("still labels RENDER_FAILED and NODE_NOT_FOUND (thrown outside the shared get())", async () => {
const nullRender = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(200, { images: { "1:2": null } })).fetch,
});
const renderErr = await nullRender
.renderNode({ fileKey: "F", nodeId: "1:2" }, { format: "svg" })
.catch((e: unknown) => e);
expect(renderErr).toBeInstanceOf(FigmaClientError);
if (renderErr instanceof FigmaClientError) expect(renderErr.endpoint).toBe("images");
const missingNode = createFigmaClient({
token: "t",
fetch: fetchStub(() => jsonResponse(200, { nodes: {} })).fetch,
});
const notFoundErr = await missingNode
.nodeTree({ fileKey: "F", nodeId: "9:9" })
.catch((e: unknown) => e);
expect(notFoundErr).toBeInstanceOf(FigmaClientError);
if (notFoundErr instanceof FigmaClientError) expect(notFoundErr.endpoint).toBe("files_nodes");
});
});
describe("renderNodes (batch)", () => {
it("fetches many nodes in ONE /v1/images call and maps each url", async () => {
const stub = fetchStub(() =>
+35 -5
View File
@@ -14,12 +14,16 @@ export type FigmaClientErrorCode =
export class FigmaClientError extends Error {
readonly code: FigmaClientErrorCode;
readonly status?: number;
/** Low-cardinality REST call label (e.g. "images", "files_nodes") for
* telemetry attribution — never the raw fileKey/nodeId path. */
readonly endpoint?: string;
constructor(code: FigmaClientErrorCode, message: string, status?: number) {
constructor(code: FigmaClientErrorCode, message: string, status?: number, endpoint?: string) {
super(message);
this.name = "FigmaClientError";
this.code = code;
this.status = status;
this.endpoint = endpoint;
}
}
@@ -217,6 +221,8 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
enterpriseGated?: boolean;
/** scope named in a FORBIDDEN message so the user knows which to add. */
scopeHint?: string;
/** low-cardinality call label carried onto any thrown FigmaClientError. */
endpoint: string;
}
/** Map a 403 to the right typed error using figma's own response body:
@@ -233,18 +239,21 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"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,
opts.endpoint,
);
if (opts.enterpriseGated)
return new FigmaClientError(
"REQUIRES_ENTERPRISE",
"figma variables require an Enterprise plan (403) — fall back to styles",
403,
opts.endpoint,
);
if (body && /scope/i.test(body))
return new FigmaClientError(
"FORBIDDEN",
`figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`,
403,
opts.endpoint,
);
const scopeLine = opts.scopeHint
? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.`
@@ -253,6 +262,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"FORBIDDEN",
`figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`,
403,
opts.endpoint,
);
}
@@ -264,6 +274,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"BAD_TOKEN",
"figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
401,
opts.endpoint,
);
if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts);
if (res.status === 429)
@@ -271,15 +282,17 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
"RATE_LIMITED",
`figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`,
429,
opts.endpoint,
);
throw new FigmaClientError(
"HTTP_ERROR",
`figma request failed: HTTP ${res.status} ${path}`,
res.status,
opts.endpoint,
);
}
async function get(path: string, opts: GetOptions = {}): Promise<unknown> {
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.
@@ -302,6 +315,8 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
throw new FigmaClientError(
"RENDER_FAILED",
`figma could not render node ${nodeId} as ${opts.format}`,
undefined,
"images",
);
return { url: result.url, ext: opts.format };
},
@@ -314,6 +329,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
const body = await get(`/v1/images/${fileKey}?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
endpoint: "images",
});
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
return nodeIds.map((nodeId) => {
@@ -327,7 +343,10 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},
async imageFills(fileKey) {
const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent });
const body = await get(`/v1/files/${fileKey}/images`, {
scopeHint: SCOPE_HINTS.fileContent,
endpoint: "files_images",
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const images = isRecord(meta.images) ? meta.images : {};
const out = new Map<string, string>();
@@ -338,7 +357,10 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
},
async variables(fileKey) {
const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true });
const body = await get(`/v1/files/${fileKey}/variables/local`, {
enterpriseGated: true,
endpoint: "variables_local",
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const variables = isRecord(meta.variables) ? meta.variables : {};
const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {};
@@ -353,6 +375,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
async styles(fileKey) {
const body = await get(`/v1/files/${fileKey}/styles`, {
scopeHint: SCOPE_HINTS.libraryContent,
endpoint: "styles",
});
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
const styles = Array.isArray(meta.styles) ? meta.styles : [];
@@ -370,6 +393,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
const params = new URLSearchParams({ ids: nodeId, geometry: "paths" });
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, {
scopeHint: SCOPE_HINTS.fileContent,
endpoint: "files_nodes",
});
const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {};
const entry = nodes[nodeId];
@@ -380,13 +404,19 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
typeof doc.name !== "string" ||
typeof doc.type !== "string"
)
throw new FigmaClientError("NODE_NOT_FOUND", `node ${nodeId} not found in ${ref.fileKey}`);
throw new FigmaClientError(
"NODE_NOT_FOUND",
`node ${nodeId} not found in ${ref.fileKey}`,
undefined,
"files_nodes",
);
return { ...doc, id: doc.id, name: doc.name, type: doc.type };
},
async fileVersion(fileKey) {
const body = await get(`/v1/files/${fileKey}?depth=1`, {
scopeHint: SCOPE_HINTS.fileMetadata,
endpoint: "file_meta",
});
const version = isRecord(body) && typeof body.version === "string" ? body.version : "";
const lastModified =