@anyslate/cli 0.3.0 → 0.4.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 +125 -32
- package/package.json +2 -2
- package/src/auth.mjs +189 -32
- package/src/backoff.mjs +178 -0
- package/src/commands/checkpoint.mjs +13 -3
- package/src/commands/doctor.mjs +19 -0
- package/src/commands/hook.mjs +71 -3
- package/src/commands/login.mjs +36 -4
- package/src/commands/logout.mjs +4 -0
- package/src/commands/upload-artifact.mjs +12 -4
- package/src/credentials.mjs +32 -5
- package/src/guard.mjs +381 -0
- package/src/index.mjs +12 -0
- package/src/mcp-client.mjs +16 -5
package/src/guard.mjs
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Client-side circuit breaker and local call ceiling — `~/.anyslate/cli-guard.json`.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS HAS TO BE ON DISK. Every hook invocation is its own process. An
|
|
4
|
+
// in-memory breaker would be reconstructed, empty, fifty times a minute, which
|
|
5
|
+
// is exactly the shape of the 2026-08-02 overload: one machine with a stale
|
|
6
|
+
// refresh token issued `POST /oauth/token` + `POST /mcp` continuously for hours
|
|
7
|
+
// because each new process started with no memory that the last fifty had all
|
|
8
|
+
// been rejected. The breaker is only useful if it OUTLIVES the process.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT ENFORCES
|
|
11
|
+
// * after repeated 401s (or one definitively dead credential) capture is
|
|
12
|
+
// paused for an escalating period, the user is told ONCE how to fix it
|
|
13
|
+
// (`anyslate login`), and until then the client makes no request at all,
|
|
14
|
+
// * a `Retry-After` from a 429/503 becomes a persisted pause, so the wait is
|
|
15
|
+
// honoured by whichever process runs next rather than by a sleeping hook,
|
|
16
|
+
// * repeated transport failures (the server saying "overloaded") pause too,
|
|
17
|
+
// so a struggling service is not kept under load by its own clients,
|
|
18
|
+
// * a generous per-minute ceiling on calls from this machine, as a backstop
|
|
19
|
+
// against a runaway agent that is succeeding at fifty calls a minute.
|
|
20
|
+
//
|
|
21
|
+
// FINGERPRINTING, SO A FIX IS NOT PUNISHED. The breaker is bound to the
|
|
22
|
+
// (apiUrl, credential) pair that tripped it. Point the CLI at a different
|
|
23
|
+
// service root, swap the token, or run `anyslate login`, and it no longer
|
|
24
|
+
// applies — otherwise "I fixed my config" would still mean fifteen minutes of
|
|
25
|
+
// dead capture. An OAuth session is fingerprinted by its `client_id`, which
|
|
26
|
+
// survives refresh-token rotation; if it did not, every refresh would silently
|
|
27
|
+
// reset the breaker and we would be back to the storm.
|
|
28
|
+
//
|
|
29
|
+
// FAILS OPEN, ALWAYS. Every function here swallows its own I/O errors. A
|
|
30
|
+
// guard file we cannot read or write must never be the reason a user's capture
|
|
31
|
+
// or login stops working — a local cache blip converting into a lockout is a
|
|
32
|
+
// worse failure than the one this module prevents.
|
|
33
|
+
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { unlinkSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { anyslateDir } from './config.mjs';
|
|
38
|
+
import { readJsonFile, writeJsonFile } from './credentials.mjs';
|
|
39
|
+
|
|
40
|
+
export const GUARD_FILE = 'cli-guard.json';
|
|
41
|
+
|
|
42
|
+
/** Consecutive 401s before capture is paused. One is a blip; three is a state. */
|
|
43
|
+
export const AUTH_FAILURES_BEFORE_OPEN = 3;
|
|
44
|
+
|
|
45
|
+
/** Consecutive transport failures before we stop leaning on a struggling host. */
|
|
46
|
+
export const TRANSIENT_FAILURES_BEFORE_OPEN = 5;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Escalating pauses. A credential that is still dead after 15 minutes is not
|
|
50
|
+
* going to be alive at 16, and the user has already been told what to do — so
|
|
51
|
+
* the client backs further off rather than re-asking every quarter hour.
|
|
52
|
+
*/
|
|
53
|
+
export const AUTH_PAUSE_LADDER_MS = [15 * 60_000, 60 * 60_000, 6 * 60 * 60_000, 24 * 60 * 60_000];
|
|
54
|
+
export const TRANSIENT_PAUSE_LADDER_MS = [30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];
|
|
55
|
+
|
|
56
|
+
/** Clamp on a server-named wait: long enough to respect, short enough to recover from. */
|
|
57
|
+
export const MIN_THROTTLE_MS = 1_000;
|
|
58
|
+
export const MAX_THROTTLE_MS = 60 * 60_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Machine-wide ceiling on capture calls per rolling minute. Set well above any
|
|
62
|
+
* human session (a busy Claude Code hour is single-digit calls per minute) —
|
|
63
|
+
* it exists to cap a runaway loop, not to shape normal traffic.
|
|
64
|
+
* `ANYSLATE_MAX_CALLS_PER_MINUTE=0` disables it.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_CALLS_PER_MINUTE = 60;
|
|
67
|
+
|
|
68
|
+
/** Auth failures that no amount of retrying can resolve — pause on the first one. */
|
|
69
|
+
const TERMINAL_AUTH_CODES = new Set([
|
|
70
|
+
'invalid_grant',
|
|
71
|
+
'no_refresh_token',
|
|
72
|
+
'no_oauth_credentials',
|
|
73
|
+
'no_credentials',
|
|
74
|
+
'unauthorized_client',
|
|
75
|
+
'invalid_client',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export function guardPath(env = process.env) {
|
|
79
|
+
return join(anyslateDir(env), GUARD_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
84
|
+
* @returns {Record<string, any>}
|
|
85
|
+
*/
|
|
86
|
+
export function readGuard(env = process.env) {
|
|
87
|
+
return readJsonFile(GUARD_FILE, env);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {Record<string, any>} next
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
93
|
+
* @returns {boolean} whether it landed — callers proceed either way
|
|
94
|
+
*/
|
|
95
|
+
export function writeGuard(next, env = process.env) {
|
|
96
|
+
try {
|
|
97
|
+
writeJsonFile(GUARD_FILE, next, env);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Identify the (endpoint, credential) pair a breaker state belongs to, without
|
|
106
|
+
* storing anything a leaked guard file could be used with. Hashed for the same
|
|
107
|
+
* reason the run log is redacted: this file is written to a shared home
|
|
108
|
+
* directory and read by `doctor`.
|
|
109
|
+
*
|
|
110
|
+
* @param {{apiUrl?: string, authMode?: string, oauth?: object|null, mcpToken?: string|null,
|
|
111
|
+
* staticToken?: string|null, sources?: {mcpToken?: string}}} cfg
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function authFingerprint(cfg) {
|
|
115
|
+
const apiUrl = String(cfg?.apiUrl ?? '');
|
|
116
|
+
let identity = 'none';
|
|
117
|
+
if (cfg?.sources?.mcpToken === 'env' && cfg?.mcpToken) {
|
|
118
|
+
identity = `env:${cfg.mcpToken}`;
|
|
119
|
+
} else if (cfg?.oauth && (cfg.oauth.access_token || cfg.oauth.refresh_token)) {
|
|
120
|
+
// client_id, NOT the tokens: rotation replaces those on every refresh, and a
|
|
121
|
+
// fingerprint that changes hourly is a breaker that never holds.
|
|
122
|
+
identity = `oauth:${cfg.oauth.client_id ?? ''}:${cfg.oauth.root ?? ''}`;
|
|
123
|
+
} else if (cfg?.staticToken || cfg?.mcpToken) {
|
|
124
|
+
identity = `static:${cfg.staticToken ?? cfg.mcpToken}`;
|
|
125
|
+
}
|
|
126
|
+
return createHash('sha256').update(`${apiUrl}|${identity}`).digest('hex').slice(0, 16);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Is capture paused right now for this config?
|
|
131
|
+
*
|
|
132
|
+
* Pure read — the caller decides whether to speak, then calls `markNotified`.
|
|
133
|
+
* Splitting it that way keeps "tell the user once" honest across processes: the
|
|
134
|
+
* flag is only set once something was actually printed.
|
|
135
|
+
*
|
|
136
|
+
* @param {{cfg: object, env?: NodeJS.ProcessEnv, now?: number}} opts
|
|
137
|
+
* @returns {{open: boolean, reason: string|null, detail: string|null, until: string|null,
|
|
138
|
+
* pausedForMs: number, noticeDue: boolean}}
|
|
139
|
+
*/
|
|
140
|
+
export function breakerGate({ cfg, env = process.env, now = Date.now() }) {
|
|
141
|
+
const closed = { open: false, reason: null, detail: null, until: null, pausedForMs: 0, noticeDue: false };
|
|
142
|
+
const state = readGuard(env);
|
|
143
|
+
if (!state.open_until) return closed;
|
|
144
|
+
|
|
145
|
+
// A different endpoint or credential is a different problem; do not serve a
|
|
146
|
+
// stale verdict about a setup the user has since changed.
|
|
147
|
+
if (state.fingerprint && state.fingerprint !== authFingerprint(cfg)) return closed;
|
|
148
|
+
|
|
149
|
+
const until = Date.parse(String(state.open_until));
|
|
150
|
+
if (!Number.isFinite(until) || until <= now) return closed;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
open: true,
|
|
154
|
+
reason: state.reason ?? 'auth',
|
|
155
|
+
detail: state.detail ?? null,
|
|
156
|
+
until: new Date(until).toISOString(),
|
|
157
|
+
pausedForMs: until - now,
|
|
158
|
+
noticeDue: !state.notified_at,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The one line a paused client is allowed to say. Names the fix, names when it
|
|
164
|
+
* will try again, and never repeats itself on the hook path.
|
|
165
|
+
*
|
|
166
|
+
* @param {{reason: string|null, detail: string|null, until: string|null}} gate
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function breakerNotice(gate) {
|
|
170
|
+
const until = gate.until ? ` until ${gate.until}` : '';
|
|
171
|
+
const detail = gate.detail ? ` (${gate.detail})` : '';
|
|
172
|
+
if (gate.reason === 'auth') {
|
|
173
|
+
return (
|
|
174
|
+
`anyslate: capture paused${until} — repeated authentication failures${detail}. ` +
|
|
175
|
+
'Run `anyslate login` to sign in again; no requests are sent until then.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (gate.reason === 'throttled') {
|
|
179
|
+
return `anyslate: capture paused${until} — the server asked this client to back off${detail}.`;
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
`anyslate: capture paused${until} — repeated failures reaching the service${detail}. ` +
|
|
183
|
+
'Run `anyslate doctor` to check it.'
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record that a call was rejected, and open the breaker if this is a pattern.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} opts
|
|
191
|
+
* @param {object} opts.cfg
|
|
192
|
+
* @param {'auth'|'throttled'|'transient'|'fatal'|null} opts.kind
|
|
193
|
+
* @param {string} [opts.code] OAuth/refresh error code, when there is one
|
|
194
|
+
* @param {string} [opts.detail] short human reason, stored for `doctor`
|
|
195
|
+
* @param {number|null} [opts.retryAfterMs]
|
|
196
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
197
|
+
* @param {number} [opts.now]
|
|
198
|
+
* @returns {{opened: boolean, open: boolean, reason: string|null, until: string|null, detail: string|null}}
|
|
199
|
+
*/
|
|
200
|
+
export function recordCallFailure({ cfg, kind, code, detail, retryAfterMs = null, env = process.env, now = Date.now() }) {
|
|
201
|
+
const quiet = { opened: false, open: false, reason: null, until: null, detail: null };
|
|
202
|
+
// A 4xx we caused is not a reason to stop capturing: the request was cheap to
|
|
203
|
+
// reject and the next one may be well-formed.
|
|
204
|
+
if (kind !== 'auth' && kind !== 'throttled' && kind !== 'transient') return quiet;
|
|
205
|
+
|
|
206
|
+
const fingerprint = authFingerprint(cfg);
|
|
207
|
+
const previous = readGuard(env);
|
|
208
|
+
// Counters belong to one (endpoint, credential) pair; a switch resets them.
|
|
209
|
+
const carried = previous.fingerprint === fingerprint ? previous : {};
|
|
210
|
+
|
|
211
|
+
const next = {
|
|
212
|
+
fingerprint,
|
|
213
|
+
auth_failures: Number(carried.auth_failures) || 0,
|
|
214
|
+
transient_failures: Number(carried.transient_failures) || 0,
|
|
215
|
+
trips: Number(carried.trips) || 0,
|
|
216
|
+
open_until: carried.open_until ?? null,
|
|
217
|
+
reason: carried.reason ?? null,
|
|
218
|
+
detail: carried.detail ?? null,
|
|
219
|
+
notified_at: carried.notified_at ?? null,
|
|
220
|
+
window_started_at: carried.window_started_at ?? null,
|
|
221
|
+
window_calls: Number(carried.window_calls) || 0,
|
|
222
|
+
updated_at: new Date(now).toISOString(),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let pauseMs = 0;
|
|
226
|
+
if (kind === 'throttled') {
|
|
227
|
+
// One is enough. `Retry-After` is an instruction, not a hint, and the
|
|
228
|
+
// machine that ignored it is the one that took the database down.
|
|
229
|
+
pauseMs = clamp(retryAfterMs ?? TRANSIENT_PAUSE_LADDER_MS[0], MIN_THROTTLE_MS, MAX_THROTTLE_MS);
|
|
230
|
+
next.reason = 'throttled';
|
|
231
|
+
} else if (kind === 'auth') {
|
|
232
|
+
next.auth_failures += 1;
|
|
233
|
+
const terminal = code ? TERMINAL_AUTH_CODES.has(code) : false;
|
|
234
|
+
if (terminal || next.auth_failures >= AUTH_FAILURES_BEFORE_OPEN) {
|
|
235
|
+
pauseMs = ladder(AUTH_PAUSE_LADDER_MS, next.trips);
|
|
236
|
+
next.reason = 'auth';
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
next.transient_failures += 1;
|
|
240
|
+
if (next.transient_failures >= TRANSIENT_FAILURES_BEFORE_OPEN) {
|
|
241
|
+
pauseMs = ladder(TRANSIENT_PAUSE_LADDER_MS, next.trips);
|
|
242
|
+
next.reason = 'server';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!pauseMs) {
|
|
247
|
+
writeGuard(next, env);
|
|
248
|
+
return quiet;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
next.open_until = new Date(now + pauseMs).toISOString();
|
|
252
|
+
next.detail = detail ? String(detail).slice(0, 200) : (code ?? null);
|
|
253
|
+
next.trips += 1;
|
|
254
|
+
next.auth_failures = 0;
|
|
255
|
+
next.transient_failures = 0;
|
|
256
|
+
// Re-arm the "say it once" flag: a NEW pause deserves a fresh notice.
|
|
257
|
+
next.notified_at = null;
|
|
258
|
+
writeGuard(next, env);
|
|
259
|
+
|
|
260
|
+
return { opened: true, open: true, reason: next.reason, until: next.open_until, detail: next.detail };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A call landed. Everything the breaker knew is stale.
|
|
265
|
+
*
|
|
266
|
+
* The rolling-minute window is deliberately preserved — a successful call still
|
|
267
|
+
* counts against the machine ceiling.
|
|
268
|
+
*
|
|
269
|
+
* @param {{cfg: object, env?: NodeJS.ProcessEnv}} opts
|
|
270
|
+
*/
|
|
271
|
+
export function recordCallSuccess({ cfg, env = process.env }) {
|
|
272
|
+
const previous = readGuard(env);
|
|
273
|
+
if (!previous.open_until && !previous.auth_failures && !previous.transient_failures && !previous.trips) return;
|
|
274
|
+
writeGuard(
|
|
275
|
+
{
|
|
276
|
+
fingerprint: authFingerprint(cfg),
|
|
277
|
+
auth_failures: 0,
|
|
278
|
+
transient_failures: 0,
|
|
279
|
+
trips: 0,
|
|
280
|
+
open_until: null,
|
|
281
|
+
reason: null,
|
|
282
|
+
detail: null,
|
|
283
|
+
notified_at: null,
|
|
284
|
+
window_started_at: previous.window_started_at ?? null,
|
|
285
|
+
window_calls: Number(previous.window_calls) || 0,
|
|
286
|
+
updated_at: new Date().toISOString(),
|
|
287
|
+
},
|
|
288
|
+
env,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Mark the pause as explained, so the next hook stays quiet.
|
|
294
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
295
|
+
* @param {number} [now]
|
|
296
|
+
*/
|
|
297
|
+
export function markNotified(env = process.env, now = Date.now()) {
|
|
298
|
+
const state = readGuard(env);
|
|
299
|
+
if (!state.open_until) return;
|
|
300
|
+
writeGuard({ ...state, notified_at: new Date(now).toISOString() }, env);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Drop the breaker entirely. `login` calls this: the user has just proved they
|
|
305
|
+
* can authenticate, so making them wait out a pause earned by the credential
|
|
306
|
+
* they just replaced would be absurd.
|
|
307
|
+
*
|
|
308
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
309
|
+
*/
|
|
310
|
+
export function clearBreaker(env = process.env) {
|
|
311
|
+
try {
|
|
312
|
+
unlinkSync(guardPath(env));
|
|
313
|
+
} catch {
|
|
314
|
+
// Never existed, or the home directory is not ours to write. Either way the
|
|
315
|
+
// breaker reads as closed, which is the fail-open answer.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The machine-wide ceiling. Read-modify-write, so two hooks racing in the same
|
|
321
|
+
* second can undercount — that is the intended direction to be wrong in: this
|
|
322
|
+
* is a runaway backstop, not an accounting system, and a lost increment lets a
|
|
323
|
+
* legitimate call through rather than dropping it.
|
|
324
|
+
*
|
|
325
|
+
* @param {{env?: NodeJS.ProcessEnv, now?: number, limit?: number}} [opts]
|
|
326
|
+
* @returns {{allowed: boolean, count: number, limit: number, resetInMs: number}}
|
|
327
|
+
*/
|
|
328
|
+
export function reserveCallSlot({ env = process.env, now = Date.now(), limit } = {}) {
|
|
329
|
+
const ceiling = limit ?? callsPerMinuteLimit(env);
|
|
330
|
+
if (!ceiling) return { allowed: true, count: 0, limit: 0, resetInMs: 0 };
|
|
331
|
+
|
|
332
|
+
const state = readGuard(env);
|
|
333
|
+
const startedAt = Date.parse(String(state.window_started_at ?? ''));
|
|
334
|
+
const fresh = !Number.isFinite(startedAt) || now - startedAt >= 60_000;
|
|
335
|
+
const windowStart = fresh ? now : startedAt;
|
|
336
|
+
const count = (fresh ? 0 : Number(state.window_calls) || 0) + 1;
|
|
337
|
+
|
|
338
|
+
writeGuard(
|
|
339
|
+
{ ...state, window_started_at: new Date(windowStart).toISOString(), window_calls: count },
|
|
340
|
+
env,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
allowed: count <= ceiling,
|
|
345
|
+
count,
|
|
346
|
+
limit: ceiling,
|
|
347
|
+
resetInMs: Math.max(0, windowStart + 60_000 - now),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
353
|
+
* @returns {number} 0 disables the ceiling
|
|
354
|
+
*/
|
|
355
|
+
export function callsPerMinuteLimit(env = process.env) {
|
|
356
|
+
const raw = env.ANYSLATE_MAX_CALLS_PER_MINUTE;
|
|
357
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CALLS_PER_MINUTE;
|
|
358
|
+
const n = Number(raw);
|
|
359
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_CALLS_PER_MINUTE;
|
|
360
|
+
return Math.floor(n);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {{count: number, limit: number, resetInMs: number}} slot
|
|
365
|
+
* @returns {string}
|
|
366
|
+
*/
|
|
367
|
+
export function ceilingNotice(slot) {
|
|
368
|
+
return (
|
|
369
|
+
`anyslate: local rate ceiling reached (${slot.count} calls in the last minute, limit ${slot.limit}) — ` +
|
|
370
|
+
`skipping this one for ${Math.ceil(slot.resetInMs / 1000)}s. ` +
|
|
371
|
+
'Raise or disable it with ANYSLATE_MAX_CALLS_PER_MINUTE.'
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function ladder(steps, index) {
|
|
376
|
+
return steps[Math.min(index, steps.length - 1)];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function clamp(value, min, max) {
|
|
380
|
+
return Math.min(max, Math.max(min, value));
|
|
381
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -78,6 +78,18 @@ env:
|
|
|
78
78
|
ANYSLATE_DISABLE=1 kill switch: hook / checkpoint / upload-artifact make no
|
|
79
79
|
network call and exit 0. \`doctor\` and \`login\` still run
|
|
80
80
|
so you can diagnose and set up while capture is off.
|
|
81
|
+
ANYSLATE_HOOK_TIMEOUT_MS
|
|
82
|
+
total request budget for one \`hook\` submission
|
|
83
|
+
(default 5000). Hooks never retry.
|
|
84
|
+
ANYSLATE_MAX_CALLS_PER_MINUTE
|
|
85
|
+
machine-wide ceiling on capture calls (default 60;
|
|
86
|
+
0 disables). A backstop against a runaway loop.
|
|
87
|
+
|
|
88
|
+
capture pauses:
|
|
89
|
+
After repeated authentication failures — or when the server answers 429/503
|
|
90
|
+
with a Retry-After — the CLI stops calling entirely and records the pause in
|
|
91
|
+
~/.anyslate/cli-guard.json. It says so once, then stays silent. \`anyslate
|
|
92
|
+
login\` clears an auth pause immediately; \`anyslate doctor\` always reports one.
|
|
81
93
|
`;
|
|
82
94
|
|
|
83
95
|
/**
|
package/src/mcp-client.mjs
CHANGED
|
@@ -99,9 +99,20 @@ export function formatErrorPayload(raw) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
*
|
|
102
|
+
* Statuses that carry a server-named wait.
|
|
103
|
+
*
|
|
104
|
+
* 503 joined 429 after 2026-08-02: when the database was overloaded the
|
|
105
|
+
* handlers returned bare 500s, every client read that as "try again now", and
|
|
106
|
+
* the retries deepened the outage. The service now answers 503 + `Retry-After`
|
|
107
|
+
* on that path, so the client has to read it there too — an unread Retry-After
|
|
108
|
+
* is the same as no Retry-After.
|
|
109
|
+
*/
|
|
110
|
+
const THROTTLE_STATUSES = new Set([429, 503]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract throttle metadata. `Retry-After` was never read and
|
|
103
114
|
* `retry_after_seconds`/`reset_at`/`reason` survived only in `raw`
|
|
104
|
-
* (defect #31). Surfacing only —
|
|
115
|
+
* (defect #31). Surfacing only — backoff.mjs/guard.mjs decide what to do with it.
|
|
105
116
|
*
|
|
106
117
|
* @param {Response} res
|
|
107
118
|
* @param {unknown} raw
|
|
@@ -294,7 +305,7 @@ export async function callTool({
|
|
|
294
305
|
const formatted = formatErrorPayload(init.raw);
|
|
295
306
|
const errLike = formatted ?? { message: `initialize failed (HTTP ${init.status})` };
|
|
296
307
|
const out = { ok: false, status: init.status, data: errLike, raw: init.raw };
|
|
297
|
-
if (init.status
|
|
308
|
+
if (THROTTLE_STATUSES.has(init.status)) {
|
|
298
309
|
const rl = extractRateLimit(init.res, init.raw);
|
|
299
310
|
out.rateLimit = rl;
|
|
300
311
|
out.retryAfterSeconds = rl.retry_after_seconds;
|
|
@@ -322,7 +333,7 @@ export async function callTool({
|
|
|
322
333
|
|
|
323
334
|
const status = res.status;
|
|
324
335
|
const raw = await readBody(res);
|
|
325
|
-
const rateLimit = status
|
|
336
|
+
const rateLimit = THROTTLE_STATUSES.has(status) ? extractRateLimit(res, raw) : null;
|
|
326
337
|
|
|
327
338
|
const finish = (out) => {
|
|
328
339
|
if (rateLimit) {
|
|
@@ -356,7 +367,7 @@ export async function callTool({
|
|
|
356
367
|
const embeddedStatus = Number.isFinite(parsed.status) ? Number(parsed.status) : status;
|
|
357
368
|
const data = formatErrorPayload(parsed) ?? parsed.error ?? text ?? 'tool returned isError';
|
|
358
369
|
const out = { ok: false, status: embeddedStatus, data, raw, isError: true };
|
|
359
|
-
if (embeddedStatus
|
|
370
|
+
if (THROTTLE_STATUSES.has(embeddedStatus)) {
|
|
360
371
|
const rl = extractRateLimit(res, parsed);
|
|
361
372
|
out.rateLimit = rl;
|
|
362
373
|
out.retryAfterSeconds = rl.retry_after_seconds;
|