-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathfetch-json.ts
More file actions
144 lines (132 loc) · 5.31 KB
/
fetch-json.ts
File metadata and controls
144 lines (132 loc) · 5.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
import { tagError, type ErrorCategory } from "~/utils/error-meta.js";
type RequestOptions = RequestInit & {
/**
* Force format of headers to be a record of strings, e.g. { "Authorization": "Bearer 123" }
*
* Avoids complexity of needing to deal with `instanceof Headers`, which is not supported in some environments.
*/
headers?: Record<string, string>;
/**
* Secrets to scrub from the response body before it's attached to a thrown
* HttpError. Defense in depth: api.figma.com never echoes credentials, but
* HTTP intermediaries (corporate proxies, MITM filters) sometimes mirror
* request metadata into error pages.
*/
redactFromResponseBody?: string[];
};
/**
* Error thrown on HTTP failures. Carries the response headers so callers can
* read rate-limit metadata without needing the original Response object, plus
* the (whitespace-collapsed, secret-redacted, truncated) response body so
* callers can distinguish Figma errors from proxy/intermediary errors.
*
* Modeled as a class rather than a structural Error extension so consumers
* get a real `instanceof HttpError` check instead of an unsafe cast.
*/
export class HttpError extends Error {
readonly responseHeaders: Record<string, string>;
readonly responseBody: string | undefined;
constructor(
message: string,
opts: { responseHeaders: Record<string, string>; responseBody: string | undefined },
) {
super(message);
this.name = "HttpError";
this.responseHeaders = opts.responseHeaders;
this.responseBody = opts.responseBody;
}
}
const CONNECTION_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"ENOTFOUND",
"UND_ERR_CONNECT_TIMEOUT",
]);
// HTTP statuses where retrying might succeed: rate limits and transient
// server-side failures. 4xx other than 429 are caller errors and not retryable.
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
// Cap the attached response body. Corp-firewall HTML blocks can be 50KB+;
// we only need enough to identify the origin ("Blocked by Zscaler", etc.).
const MAX_RESPONSE_BODY_CHARS = 500;
export async function fetchJSON<T extends { status?: number }>(
url: string,
options: RequestOptions = {},
): Promise<{ data: T; rawSize: number }> {
const { redactFromResponseBody = [], ...fetchOptions } = options;
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const responseBody = await readResponseBody(response, redactFromResponseBody.filter(Boolean));
const bodySuffix = responseBody ? `\nResponse body: ${responseBody}` : "";
const httpError = new HttpError(
`Fetch failed with status ${response.status}: ${response.statusText}${bodySuffix}`,
{ responseHeaders, responseBody },
);
tagError(httpError, {
http_status: response.status,
category: httpStatusCategory(response.status),
is_retryable: RETRYABLE_STATUSES.has(response.status),
});
}
// Read as text first so we can measure the raw body size for telemetry,
// then parse. This is the same work response.json() does internally, just
// split so we can observe the byte count before parsing.
const text = await response.text();
const rawSize = Buffer.byteLength(text, "utf8");
const data = JSON.parse(text) as T;
return { data, rawSize };
} catch (error: unknown) {
const networkCode = getConnectionErrorCode(error);
if (networkCode) {
const message = error instanceof Error ? error.message : String(error);
const wrapped = new Error(
`${message}\n\nCould not connect to the Figma API. If your network requires a proxy, ` +
`set the --proxy flag in your MCP server config or the FIGMA_PROXY environment variable ` +
`to your proxy URL (e.g. http://proxy:8080).`,
{ cause: error },
);
tagError(wrapped, { network_code: networkCode, category: "network", is_retryable: true });
}
throw error;
}
}
function getConnectionErrorCode(error: unknown): string | undefined {
if (!(error instanceof Error)) return undefined;
const cause = (error as { cause?: { code?: string } }).cause;
if (cause?.code && CONNECTION_ERROR_CODES.has(cause.code)) return cause.code;
return undefined;
}
function httpStatusCategory(status: number): ErrorCategory {
if (status === 429) return "rate_limit";
if (status === 401 || status === 403) return "auth";
return "figma_api";
}
async function readResponseBody(
response: Response,
redactSecrets: string[],
): Promise<string | undefined> {
// Body read can fail if the connection is killed mid-response; we'd rather
// surface the status/headers we already have than mask it with a body-read
// error.
let text: string;
try {
text = await response.text();
} catch {
return undefined;
}
if (!text) return undefined;
// Collapse whitespace so HTML error pages read as one line in error messages.
let result = text.replace(/\s+/g, " ").trim();
for (const secret of redactSecrets) {
result = result.replaceAll(secret, "[REDACTED]");
}
if (result.length > MAX_RESPONSE_BODY_CHARS) {
result = result.slice(0, MAX_RESPONSE_BODY_CHARS) + "… [truncated]";
}
return result;
}