@anyslate/cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +313 -19
- package/package.json +6 -6
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +61 -18
- package/src/commands/doctor.mjs +616 -0
- package/src/commands/hook.mjs +81 -23
- package/src/commands/login.mjs +362 -25
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +136 -26
- package/src/config.mjs +162 -13
- package/src/credentials.mjs +162 -0
- package/src/hooks.mjs +170 -8
- package/src/index.mjs +61 -6
- package/src/io.mjs +30 -0
- package/src/mcp-client.mjs +291 -45
- package/src/oauth.mjs +633 -0
- package/src/runlog.mjs +196 -0
- package/src/stdin.mjs +85 -15
- package/src/verify.mjs +262 -0
- package/src/version.mjs +21 -0
- package/templates/git/post-commit +71 -0
package/src/mcp-client.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Minimal JSON-RPC client for the AnySlate MCP service.
|
|
2
2
|
//
|
|
3
3
|
// The MCP service speaks the standard MCP JSON-RPC protocol. We only need
|
|
4
|
-
// `tools/call` from the CLI
|
|
4
|
+
// `tools/call` from the CLI - that one method covers `activity_submit`,
|
|
5
5
|
// `checkpoint_session`, and `upload_artifact`.
|
|
6
6
|
//
|
|
7
7
|
// The server replies with `{ jsonrpc: '2.0', id, result | error }`. When the
|
|
@@ -9,12 +9,34 @@
|
|
|
9
9
|
// JSON-parse it (the AnySlate dispatcher always returns JSON-encoded text).
|
|
10
10
|
//
|
|
11
11
|
// Streamable HTTP transport requires every non-initialize request to carry
|
|
12
|
-
// an `Mcp-Session-Id` header (mcp-protocol.ts:
|
|
12
|
+
// an `Mcp-Session-Id` header (mcp-protocol.ts:5825). The CLI is one-shot per
|
|
13
13
|
// invocation, so we run `initialize` first, capture the server-issued
|
|
14
14
|
// session id from the response header, and reuse it for the `tools/call`.
|
|
15
|
+
//
|
|
16
|
+
// IMPORTANT (W2): the MCP wire protocol returns **HTTP 200 with
|
|
17
|
+
// `result.isError === true`** for tool-level rejections — 403 handle denial,
|
|
18
|
+
// 400 unsupported kind, 500 insert failure, tool-level 429. Trusting
|
|
19
|
+
// `res.ok` alone reports every one of those as success. `callTool` therefore
|
|
20
|
+
// inspects `result.isError` and maps it to `ok: false` with the embedded
|
|
21
|
+
// status, which is what makes `--strict` and the checkpoint/upload-artifact
|
|
22
|
+
// fail-closed contract actually mean anything.
|
|
23
|
+
|
|
24
|
+
import { USER_AGENT } from './version.mjs';
|
|
15
25
|
|
|
16
26
|
let nextRequestId = 1;
|
|
17
27
|
|
|
28
|
+
const MCP_HEADERS = (token, sessionId) => {
|
|
29
|
+
const h = {
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
authorization: `Bearer ${token}`,
|
|
32
|
+
accept: 'application/json, text/event-stream',
|
|
33
|
+
'mcp-protocol-version': '2025-06-18',
|
|
34
|
+
'user-agent': USER_AGENT,
|
|
35
|
+
};
|
|
36
|
+
if (sessionId) h['mcp-session-id'] = sessionId;
|
|
37
|
+
return h;
|
|
38
|
+
};
|
|
39
|
+
|
|
18
40
|
async function readBody(res) {
|
|
19
41
|
const ctype = res.headers.get('content-type') || '';
|
|
20
42
|
// The MCP service may stream JSON-RPC results as text/event-stream
|
|
@@ -42,16 +64,153 @@ async function readBody(res) {
|
|
|
42
64
|
}
|
|
43
65
|
}
|
|
44
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Render a server error body as a human string, preserving `error_description`.
|
|
69
|
+
*
|
|
70
|
+
* Dropping `error_description` makes *revoked* and *mistyped* indistinguishable
|
|
71
|
+
* (defect #15) — the single most common real-world 401.
|
|
72
|
+
*
|
|
73
|
+
* @param {unknown} raw
|
|
74
|
+
* @returns {string|object|null}
|
|
75
|
+
*/
|
|
76
|
+
export function formatErrorPayload(raw) {
|
|
77
|
+
if (raw == null) return null;
|
|
78
|
+
if (typeof raw === 'string') return raw;
|
|
79
|
+
if (typeof raw !== 'object') return String(raw);
|
|
80
|
+
|
|
81
|
+
const desc =
|
|
82
|
+
typeof raw.error_description === 'string'
|
|
83
|
+
? raw.error_description
|
|
84
|
+
: typeof raw.errorDescription === 'string'
|
|
85
|
+
? raw.errorDescription
|
|
86
|
+
: null;
|
|
87
|
+
const err = raw.error;
|
|
88
|
+
|
|
89
|
+
if (typeof err === 'string' && err) {
|
|
90
|
+
return desc ? `${err}: ${desc}` : err;
|
|
91
|
+
}
|
|
92
|
+
if (err && typeof err === 'object') {
|
|
93
|
+
// JSON-RPC error object — keep the object so callers can read `.message`.
|
|
94
|
+
return err;
|
|
95
|
+
}
|
|
96
|
+
if (desc) return desc;
|
|
97
|
+
if (typeof raw.message === 'string') return raw.message;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract 429 metadata. `Retry-After` was never read and
|
|
103
|
+
* `retry_after_seconds`/`reset_at`/`reason` survived only in `raw`
|
|
104
|
+
* (defect #31). Surfacing only — no retry/backoff logic.
|
|
105
|
+
*
|
|
106
|
+
* @param {Response} res
|
|
107
|
+
* @param {unknown} raw
|
|
108
|
+
*/
|
|
109
|
+
export function extractRateLimit(res, raw) {
|
|
110
|
+
const header = res?.headers?.get ? res.headers.get('retry-after') : null;
|
|
111
|
+
const body = raw && typeof raw === 'object' ? raw : {};
|
|
112
|
+
const fromBody = body.retry_after_seconds ?? body.retryAfterSeconds;
|
|
113
|
+
const parsedHeader = header != null && header !== '' ? Number(header) : NaN;
|
|
114
|
+
const retryAfterSeconds = Number.isFinite(fromBody)
|
|
115
|
+
? Number(fromBody)
|
|
116
|
+
: Number.isFinite(parsedHeader)
|
|
117
|
+
? parsedHeader
|
|
118
|
+
: null;
|
|
119
|
+
return {
|
|
120
|
+
retry_after_seconds: retryAfterSeconds,
|
|
121
|
+
retry_after_header: header ?? null,
|
|
122
|
+
reset_at: body.reset_at ?? body.resetAt ?? null,
|
|
123
|
+
reason: body.reason ?? null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Turn a thrown fetch error into something that names the host and the cause.
|
|
129
|
+
* Previously DNS failure, connection refused and timeout were all
|
|
130
|
+
* `request failed (fetch failed)` (defect #30).
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} err
|
|
133
|
+
* @param {string} host
|
|
134
|
+
* @param {number} timeoutMs
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* Undici wraps the real cause: `fetch failed` carries `cause`, which for a
|
|
139
|
+
* refused/multi-address connect is an AggregateError whose `.errors[]` hold
|
|
140
|
+
* the codes. Walk the whole chain, or every connection error looks identical.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} err
|
|
143
|
+
* @param {number} [depth]
|
|
144
|
+
* @returns {string|null}
|
|
145
|
+
*/
|
|
146
|
+
export function findErrorCode(err, depth = 0) {
|
|
147
|
+
if (!err || depth > 5) return null;
|
|
148
|
+
if (typeof err.code === 'string' && err.code) return err.code;
|
|
149
|
+
if (Array.isArray(err.errors)) {
|
|
150
|
+
for (const inner of err.errors) {
|
|
151
|
+
const code = findErrorCode(inner, depth + 1);
|
|
152
|
+
if (code) return code;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return findErrorCode(err.cause, depth + 1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function classifyNetworkError(err, host, timeoutMs) {
|
|
159
|
+
const message = String(err?.message ?? err ?? '');
|
|
160
|
+
const code = findErrorCode(err);
|
|
161
|
+
const seconds = Math.round((timeoutMs ?? 0) / 1000);
|
|
162
|
+
|
|
163
|
+
if (err?.name === 'AbortError' || /aborted/i.test(message)) {
|
|
164
|
+
return `timed out after ${seconds}s contacting ${host}`;
|
|
165
|
+
}
|
|
166
|
+
switch (code) {
|
|
167
|
+
case 'ENOTFOUND':
|
|
168
|
+
return `ENOTFOUND ${host} (DNS)`;
|
|
169
|
+
case 'EAI_AGAIN':
|
|
170
|
+
return `EAI_AGAIN ${host} (DNS)`;
|
|
171
|
+
case 'ECONNREFUSED':
|
|
172
|
+
return `ECONNREFUSED ${host}`;
|
|
173
|
+
case 'ECONNRESET':
|
|
174
|
+
return `ECONNRESET ${host}`;
|
|
175
|
+
case 'ETIMEDOUT':
|
|
176
|
+
case 'UND_ERR_CONNECT_TIMEOUT':
|
|
177
|
+
case 'UND_ERR_HEADERS_TIMEOUT':
|
|
178
|
+
return `timed out contacting ${host} (${code})`;
|
|
179
|
+
default:
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
if (code && /CERT|SSL|TLS/i.test(code)) return `TLS error contacting ${host} (${code})`;
|
|
183
|
+
if (/certificate|self[- ]signed|SSL/i.test(message)) return `TLS error contacting ${host}`;
|
|
184
|
+
if (code) return `${code} ${host}`;
|
|
185
|
+
return `${message || 'request failed'} (${host})`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Uniform failure line for every command. Network failures are not "server
|
|
190
|
+
* <status>" failures — printing `server 0` would be a lie.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} prefix e.g. `anyslate hook stop`
|
|
193
|
+
* @param {{status: number, data: unknown, networkError?: boolean}} res
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function formatCallFailure(prefix, res) {
|
|
197
|
+
const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
|
|
198
|
+
if (res.networkError) return `${prefix}: request failed: ${msg}\n`;
|
|
199
|
+
return `${prefix}: server ${res.status} — ${msg}\n`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hostOf(url) {
|
|
203
|
+
try {
|
|
204
|
+
return new URL(url).host;
|
|
205
|
+
} catch {
|
|
206
|
+
return url;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
45
210
|
async function initializeSession({ url, token, fetchImpl, ac }) {
|
|
46
211
|
const res = await fetchImpl(url, {
|
|
47
212
|
method: 'POST',
|
|
48
|
-
headers:
|
|
49
|
-
'content-type': 'application/json',
|
|
50
|
-
authorization: `Bearer ${token}`,
|
|
51
|
-
accept: 'application/json, text/event-stream',
|
|
52
|
-
'mcp-protocol-version': '2025-06-18',
|
|
53
|
-
'user-agent': 'anyslate-cli/0.1.0',
|
|
54
|
-
},
|
|
213
|
+
headers: MCP_HEADERS(token),
|
|
55
214
|
body: JSON.stringify({
|
|
56
215
|
jsonrpc: '2.0',
|
|
57
216
|
id: nextRequestId++,
|
|
@@ -59,7 +218,7 @@ async function initializeSession({ url, token, fetchImpl, ac }) {
|
|
|
59
218
|
params: {
|
|
60
219
|
protocolVersion: '2025-06-18',
|
|
61
220
|
capabilities: {},
|
|
62
|
-
clientInfo: { name: 'anyslate-cli', version: '
|
|
221
|
+
clientInfo: { name: 'anyslate-cli', version: USER_AGENT.split('/')[1] },
|
|
63
222
|
},
|
|
64
223
|
}),
|
|
65
224
|
signal: ac.signal,
|
|
@@ -67,45 +226,61 @@ async function initializeSession({ url, token, fetchImpl, ac }) {
|
|
|
67
226
|
const sid = res.headers.get('mcp-session-id');
|
|
68
227
|
if (!res.ok) {
|
|
69
228
|
const raw = await readBody(res);
|
|
70
|
-
return { ok: false, status: res.status, sessionId: null, raw };
|
|
229
|
+
return { ok: false, status: res.status, sessionId: null, raw, res };
|
|
71
230
|
}
|
|
72
231
|
if (!sid) {
|
|
73
|
-
return {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
status: res.status,
|
|
235
|
+
sessionId: null,
|
|
236
|
+
raw: { error: 'server omitted Mcp-Session-Id header on initialize' },
|
|
237
|
+
res,
|
|
238
|
+
};
|
|
74
239
|
}
|
|
75
|
-
// Best-effort `notifications/initialized`
|
|
240
|
+
// Best-effort `notifications/initialized` - server returns 202 and we
|
|
76
241
|
// proceed regardless. Required by spec; harmless if dropped on the floor.
|
|
77
242
|
try {
|
|
78
243
|
await fetchImpl(url, {
|
|
79
244
|
method: 'POST',
|
|
80
|
-
headers:
|
|
81
|
-
'content-type': 'application/json',
|
|
82
|
-
authorization: `Bearer ${token}`,
|
|
83
|
-
accept: 'application/json, text/event-stream',
|
|
84
|
-
'mcp-protocol-version': '2025-06-18',
|
|
85
|
-
'mcp-session-id': sid,
|
|
86
|
-
'user-agent': 'anyslate-cli/0.1.0',
|
|
87
|
-
},
|
|
245
|
+
headers: MCP_HEADERS(token, sid),
|
|
88
246
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
|
|
89
247
|
signal: ac.signal,
|
|
90
248
|
});
|
|
91
249
|
} catch {
|
|
92
|
-
// Swallow
|
|
250
|
+
// Swallow - we have the session id; the notification is advisory.
|
|
93
251
|
}
|
|
94
252
|
return { ok: true, status: res.status, sessionId: sid, raw: null };
|
|
95
253
|
}
|
|
96
254
|
|
|
97
255
|
/**
|
|
98
256
|
* @param {object} opts
|
|
99
|
-
* @param {string} opts.apiUrl
|
|
257
|
+
* @param {string} opts.apiUrl service ROOT (the client appends `/mcp`)
|
|
100
258
|
* @param {string} opts.token Bearer token (MCP token).
|
|
101
259
|
* @param {string} opts.toolName
|
|
102
260
|
* @param {Record<string, unknown>} opts.args
|
|
103
261
|
* @param {typeof fetch} [opts.fetchImpl] override for tests
|
|
104
262
|
* @param {number} [opts.timeoutMs]
|
|
105
|
-
* @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown
|
|
263
|
+
* @returns {Promise<{ ok: boolean, status: number, data: unknown, raw: unknown,
|
|
264
|
+
* isError?: boolean, networkError?: boolean, retryAfterSeconds?: number|null,
|
|
265
|
+
* rateLimit?: object }>}
|
|
106
266
|
*/
|
|
107
|
-
export async function callTool({
|
|
108
|
-
|
|
267
|
+
export async function callTool({
|
|
268
|
+
apiUrl,
|
|
269
|
+
token,
|
|
270
|
+
toolName,
|
|
271
|
+
args,
|
|
272
|
+
fetchImpl = fetch,
|
|
273
|
+
timeoutMs = 15000,
|
|
274
|
+
sessionId: presetSessionId,
|
|
275
|
+
}) {
|
|
276
|
+
// `apiUrl` is already normalized by loadConfig; strip again defensively so a
|
|
277
|
+
// direct caller cannot reintroduce `/mcp/mcp`.
|
|
278
|
+
const root = String(apiUrl ?? '')
|
|
279
|
+
.trim()
|
|
280
|
+
.replace(/\/+$/, '')
|
|
281
|
+
.replace(/(\/mcp)+$/i, '');
|
|
282
|
+
const url = `${root}/mcp`;
|
|
283
|
+
const host = hostOf(url);
|
|
109
284
|
const ac = new AbortController();
|
|
110
285
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
111
286
|
|
|
@@ -116,12 +291,16 @@ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetc
|
|
|
116
291
|
if (!init.ok) {
|
|
117
292
|
// Surface the auth/init failure exactly as if the tool call itself failed,
|
|
118
293
|
// so the CLI's existing error handling (server 401 etc.) keeps working.
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
294
|
+
const formatted = formatErrorPayload(init.raw);
|
|
295
|
+
const errLike = formatted ?? { message: `initialize failed (HTTP ${init.status})` };
|
|
296
|
+
const out = { ok: false, status: init.status, data: errLike, raw: init.raw };
|
|
297
|
+
if (init.status === 429) {
|
|
298
|
+
const rl = extractRateLimit(init.res, init.raw);
|
|
299
|
+
out.rateLimit = rl;
|
|
300
|
+
out.retryAfterSeconds = rl.retry_after_seconds;
|
|
301
|
+
out.data = withRetryHint(errLike, rl);
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
125
304
|
}
|
|
126
305
|
sessionId = init.sessionId;
|
|
127
306
|
}
|
|
@@ -136,39 +315,106 @@ export async function callTool({ apiUrl, token, toolName, args, fetchImpl = fetc
|
|
|
136
315
|
|
|
137
316
|
const res = await fetchImpl(url, {
|
|
138
317
|
method: 'POST',
|
|
139
|
-
headers:
|
|
140
|
-
'content-type': 'application/json',
|
|
141
|
-
authorization: `Bearer ${token}`,
|
|
142
|
-
accept: 'application/json, text/event-stream',
|
|
143
|
-
'mcp-protocol-version': '2025-06-18',
|
|
144
|
-
'mcp-session-id': sessionId,
|
|
145
|
-
'user-agent': 'anyslate-cli/0.1.0',
|
|
146
|
-
},
|
|
318
|
+
headers: MCP_HEADERS(token, sessionId),
|
|
147
319
|
body: JSON.stringify(body),
|
|
148
320
|
signal: ac.signal,
|
|
149
321
|
});
|
|
150
322
|
|
|
151
323
|
const status = res.status;
|
|
152
324
|
const raw = await readBody(res);
|
|
325
|
+
const rateLimit = status === 429 ? extractRateLimit(res, raw) : null;
|
|
153
326
|
|
|
327
|
+
const finish = (out) => {
|
|
328
|
+
if (rateLimit) {
|
|
329
|
+
out.rateLimit = rateLimit;
|
|
330
|
+
out.retryAfterSeconds = rateLimit.retry_after_seconds;
|
|
331
|
+
out.data = withRetryHint(out.data, rateLimit);
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// JSON-RPC transport-level error.
|
|
154
337
|
if (raw && typeof raw === 'object' && 'error' in raw && raw.error) {
|
|
155
|
-
return { ok: false, status, data: raw.error, raw };
|
|
338
|
+
return finish({ ok: false, status, data: formatErrorPayload(raw) ?? raw.error, raw });
|
|
156
339
|
}
|
|
157
340
|
|
|
158
|
-
// `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
|
|
159
341
|
const result = raw && typeof raw === 'object' ? raw.result : null;
|
|
342
|
+
|
|
343
|
+
// ---- Tool-level error: HTTP 200 + result.isError === true (W2). ----
|
|
344
|
+
if (result && typeof result === 'object' && result.isError === true) {
|
|
345
|
+
const content = Array.isArray(result.content) ? result.content[0] : null;
|
|
346
|
+
const text = content && typeof content.text === 'string' ? content.text : null;
|
|
347
|
+
let parsed = null;
|
|
348
|
+
if (text) {
|
|
349
|
+
try {
|
|
350
|
+
parsed = JSON.parse(text);
|
|
351
|
+
} catch {
|
|
352
|
+
parsed = null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (parsed && typeof parsed === 'object') {
|
|
356
|
+
const embeddedStatus = Number.isFinite(parsed.status) ? Number(parsed.status) : status;
|
|
357
|
+
const data = formatErrorPayload(parsed) ?? parsed.error ?? text ?? 'tool returned isError';
|
|
358
|
+
const out = { ok: false, status: embeddedStatus, data, raw, isError: true };
|
|
359
|
+
if (embeddedStatus === 429) {
|
|
360
|
+
const rl = extractRateLimit(res, parsed);
|
|
361
|
+
out.rateLimit = rl;
|
|
362
|
+
out.retryAfterSeconds = rl.retry_after_seconds;
|
|
363
|
+
out.data = withRetryHint(data, rl);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
return finish({
|
|
368
|
+
ok: false,
|
|
369
|
+
status,
|
|
370
|
+
data: text ?? 'tool returned isError with no content',
|
|
371
|
+
raw,
|
|
372
|
+
isError: true,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// `result.content[0].text` is the AnySlate dispatcher's JSON envelope.
|
|
160
377
|
const content = result && Array.isArray(result.content) ? result.content[0] : null;
|
|
161
378
|
if (content && content.type === 'text' && typeof content.text === 'string') {
|
|
162
379
|
try {
|
|
163
380
|
const parsed = JSON.parse(content.text);
|
|
164
|
-
return { ok: res.ok, status, data: parsed, raw };
|
|
381
|
+
return finish({ ok: res.ok, status, data: parsed, raw });
|
|
165
382
|
} catch {
|
|
166
|
-
return { ok: res.ok, status, data: content.text, raw };
|
|
383
|
+
return finish({ ok: res.ok, status, data: content.text, raw });
|
|
167
384
|
}
|
|
168
385
|
}
|
|
169
386
|
|
|
170
|
-
|
|
387
|
+
// Non-2xx with no parseable JSON-RPC body: never print the literal `null`.
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
return finish({
|
|
390
|
+
ok: false,
|
|
391
|
+
status,
|
|
392
|
+
data: formatErrorPayload(raw) ?? `server returned HTTP ${status} with no JSON-RPC body`,
|
|
393
|
+
raw,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return finish({ ok: res.ok, status, data: result, raw });
|
|
398
|
+
} catch (e) {
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
status: 0,
|
|
402
|
+
data: classifyNetworkError(e, host, timeoutMs),
|
|
403
|
+
raw: null,
|
|
404
|
+
networkError: true,
|
|
405
|
+
};
|
|
171
406
|
} finally {
|
|
172
407
|
clearTimeout(timer);
|
|
173
408
|
}
|
|
174
409
|
}
|
|
410
|
+
|
|
411
|
+
function withRetryHint(data, rl) {
|
|
412
|
+
if (!rl) return data;
|
|
413
|
+
const bits = [];
|
|
414
|
+
if (rl.retry_after_seconds != null) bits.push(`retry after ${rl.retry_after_seconds}s`);
|
|
415
|
+
if (rl.reset_at) bits.push(`resets at ${rl.reset_at}`);
|
|
416
|
+
if (rl.reason) bits.push(String(rl.reason));
|
|
417
|
+
if (!bits.length) return data;
|
|
418
|
+
const base = typeof data === 'string' ? data : data == null ? 'rate limited' : JSON.stringify(data);
|
|
419
|
+
return `${base} — ${bits.join(', ')}`;
|
|
420
|
+
}
|