mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user