comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
459 lines • 24 kB
JavaScript
// HTTP 429 across every backend that talks to a provider over HTTP.
//
// WHY THIS EXISTS
// Each streaming backend had the same three lines — `if (!res.ok) throw new
// Error(\`… http ${status}: ${await res.text()}\`)` — and a 429 fell through them
// like any other failure: the turn died, and the provider's raw JSON was pasted
// into the panel. Live on kimi-k3 that read:
//
// ⚠️ The kimi-k3 turn failed: ollama backend: https://api.moonshot.ai/v1/chat/
// completions http 429: {"error":{"message":"Your account org-7df23a…<cak-fc91…>
// request reached organization max RPM: 3, please try again after 1 seconds"}}
//
// Three separate defects in one line. The turn was thrown away over a ONE SECOND
// wait, after four successful tool rounds. The user was handed a provider's
// internal envelope instead of a sentence. And that envelope carried their
// organization id and a credential-shaped token into the chat log — from which it
// went into a screenshot, which is how this bug was reported.
//
// WHAT THIS MODULE DECIDES, AND WHAT IT REFUSES TO
// A 429 is the provider telling us to wait, so the honest response is to wait —
// but only when it says HOW LONG and the wait is bounded. Everything here is
// built around that distinction:
//
// retryable — a wait window we can sit out. Retried, within a hard budget.
// quota — the account is out of credit/quota. More requests cannot help;
// retrying would burn the budget to arrive at the same answer, so
// this NEVER retries however short a window came with it.
// unknown — a 429 with no window we could parse. Surfaced immediately rather
// than retried on a guessed delay: a fabricated backoff against an
// unknown limiter is how a rate limit becomes a rate-limit storm.
//
// The default is to NOT retry. A 429 opts in by carrying a parseable window, the
// same direction of default services/download-retry.ts takes, and for the same
// reason: a wrongly-not-retried request reproduces exactly today's behaviour,
// while a wrongly-retried one hammers a limiter that just asked us to stop.
//
// NOTHING FROM THE PROVIDER IS ECHOED RAW. See sanitizeDetail.
import { redactTokens } from "../services/oauth-flow.js";
import { logger } from "../utils/logger.js";
/** How many times one request may be re-sent after a 429. */
export function rateLimitMaxRetries() {
const n = Number(process.env.COMFYUI_MCP_RATE_LIMIT_RETRIES);
return Number.isFinite(n) && n >= 0 ? n : 3;
}
/**
* Ceiling on the TOTAL time one request may spend waiting out 429s.
*
* A per-attempt cap is not enough: three 25s windows are individually reasonable
* and together are 75 seconds of a user watching a spinner. The budget is spent
* across attempts, and a window that would overrun it is declined rather than
* truncated — sleeping less than the provider asked for is a retry we already
* know is too early.
*/
export function rateLimitMaxTotalWaitMs() {
const n = Number(process.env.COMFYUI_MCP_RATE_LIMIT_MAX_WAIT_MS);
return Number.isFinite(n) && n > 0 ? n : 60_000;
}
/**
* Waits at or above this get a line in the chat; shorter ones pass in silence.
*
* A sub-second retry is invisible to the user — the turn just continues, a beat
* later — and announcing it would be noise about a non-event. A 20-second one is
* indistinguishable from a hang unless we say what is happening.
*/
export function rateLimitAnnounceMs() {
const n = Number(process.env.COMFYUI_MCP_RATE_LIMIT_ANNOUNCE_MS);
return Number.isFinite(n) && n >= 0 ? n : 2_000;
}
/** An error whose `message` is already the finished, user-facing sentence. */
export class RateLimitError extends Error {
kind;
retryAfterMs;
constructor(message, kind, retryAfterMs) {
super(message);
this.name = "RateLimitError";
this.kind = kind;
this.retryAfterMs = retryAfterMs;
}
}
/** Is this the error the retry driver gave up with? (Backends branch on it.) */
export function asRateLimitError(err) {
return err instanceof RateLimitError ? err : null;
}
/** Body markers that mean "more requests will not help until the user acts". */
const QUOTA_MARKERS = [
"insufficient_quota",
"quota_exceeded",
"billing_hard_limit_reached",
"exceeded your current quota",
"credit balance is too low",
"out of credits",
"no remaining credits",
"payment required",
];
/** `1s`, `500ms`, `6m0s`, `1.5s` — the duration form OpenAI-compatible endpoints
* use for `x-ratelimit-reset-*`. Returns ms, or null if it is not that shape. */
function parseResetDuration(raw) {
const s = raw.trim().toLowerCase();
if (!s)
return null;
const re = /(\d+(?:\.\d+)?)\s*(ms|s|m|h)/g;
let total = 0;
let saw = false;
let m;
while ((m = re.exec(s)) !== null) {
saw = true;
const n = Number(m[1]);
if (!Number.isFinite(n))
return null;
total += n * (m[2] === "ms" ? 1 : m[2] === "s" ? 1000 : m[2] === "m" ? 60_000 : 3_600_000);
}
// Reject trailing junk: "6m0s" is a duration, "6 messages" is not. The matches
// must account for the whole string once the separators are removed.
if (!saw || s.replace(re, "").replace(/[\s,]/g, "") !== "")
return null;
return Math.round(total);
}
/**
* `Retry-After`: delta-seconds or an HTTP-date (RFC 9110 §10.2.3).
*
* A date in the PAST yields 0, not a negative wait — some limiters send the
* window's start rather than its end, and a negative sleep would silently become
* an instant retry with no wait at all.
*/
function parseRetryAfter(raw) {
if (!raw)
return null;
const s = raw.trim();
if (/^\d+(\.\d+)?$/.test(s))
return Math.round(Number(s) * 1000);
const at = Date.parse(s);
if (Number.isFinite(at))
return Math.max(0, at - Date.now());
return null;
}
/** "please try again after 1 seconds", "retry in 500ms", "try again in 2 minutes". */
function parseWaitFromProse(text) {
const m = text.match(/\b(?:try again|retry|retry again|wait)\b[^.\n]{0,40}?\b(\d+(?:\.\d+)?)\s*(ms|millis(?:econds?)?|s|secs?|seconds?|m|mins?|minutes?|h|hours?)\b/i);
if (!m)
return null;
const n = Number(m[1]);
if (!Number.isFinite(n))
return null;
const unit = m[2].toLowerCase();
const mult = unit.startsWith("ms") || unit.startsWith("milli")
? 1
: unit.startsWith("s")
? 1000
: unit.startsWith("m")
? 60_000
: 3_600_000;
return Math.round(n * mult);
}
/**
* Strip anything that identifies the ACCOUNT out of a provider message.
*
* `redactTokens` covers credentials in the shapes OAuth uses, and it is applied
* first — but it knows nothing about `org-7df23a26037240f88f967fb1c64d8e3f` or
* `cak-fc91zq3o4h0b111bug391`, which is exactly what leaked. The rule here is
* shape plus the smallest amount of context needed for the one ambiguous case:
* an all-alpha long run can be either a minted id or an English word. A
* prefixed/segmented opaque run, UUID, e-mail, or explicitly labelled value is
* an id regardless of its contents. Bare all-alpha runs need either a
* rate-limit label/status context or a shape that is not one of the ordinary
* prose words covered by the table below.
*
* This is deliberately fail-closed for structured labels and fail-readable for
* prose. There is no morphology heuristic that can reliably distinguish a
* vendor-minted all-alpha id from an English word, so the small prose table is
* explicit and testable rather than pretending that vowel counts solve it.
*/
const EXPLICIT_ALPHA_IDENTIFIER_LABEL = /(?:^|[\s"'([{])(?:account|acct|user|workspace|token|key)(?:[_-](?:id|identifier)|\s+(?:id|identifier))\b\s*["']?\s*(?:[:=]\s*)?["'([{]?\s*$/i;
const DIRECT_ALPHA_IDENTIFIER_LABEL = /(?:^|[.!?;]\s*|(?:your|the)[\s-]+)(?:account|acct|user|tenant|plan)\s*(?:[,;.!?:=]\s*[\[({]?|[-_]+\s*[\[({]?|\s+)$/i;
const DIRECT_ALPHA_IDENTIFIER_WRAPPER = /(?:^|[.!?;]\s*|(?:your|the)[\s-]+)(?:account|user)\s*(?:[,;.!?:=]\s*|[-_]+\s*)?[\[({]\s*$/i;
// There is no reliable morphology or vowel heuristic that can distinguish a
// minted all-alpha id from an English word. Keep the small prose exception
// explicit and table-driven; explicit labels and wrappers never use it.
const ALPHA_RUN_CLASSIFICATION = new Map([
["compartmentalization", new Set(["bare-prose", "lexical-label-prose"])],
["counterrevolutionary", new Set(["bare-prose"])],
["internationalization", new Set(["bare-prose"])],
["nondeterministically", new Set(["bare-prose"])],
["uncharacteristically", new Set(["bare-prose", "lexical-label-prose"])],
]);
function classifyAlphaRun(run, boundary = "bare-prose") {
const base = run.split(/[-_]+/, 1)[0].toLowerCase();
return ALPHA_RUN_CLASSIFICATION.get(base)?.has(boundary) ? "prose" : "identifier";
}
function isNaturalLanguageAlphaRun(run, boundary = "bare-prose") {
return classifyAlphaRun(run, boundary) === "prose";
}
function isExplicitIdentifierValue(run) {
return /^(?:account|acct|user|workspace|token|key)(?:[_-](?:id|identifier))[-_]/i.test(run);
}
function hasExplicitAlphaIdentifierBoundary(input, offset) {
const before = input.slice(Math.max(0, offset - 96), offset);
const withoutJsonQuotes = before.replace(/\\?["']/g, "");
return (EXPLICIT_ALPHA_IDENTIFIER_LABEL.test(before) ||
EXPLICIT_ALPHA_IDENTIFIER_LABEL.test(withoutJsonQuotes) ||
DIRECT_ALPHA_IDENTIFIER_WRAPPER.test(before) ||
DIRECT_ALPHA_IDENTIFIER_WRAPPER.test(withoutJsonQuotes));
}
function firstIdentifierPrefix(run) {
const explicitLabelPrefix = run.match(/^(?:(?:the|your)[-_])?(?:account|acct|user|workspace|token|key)[-_]+(?:id|identifier)[-_]+/i)?.[0];
if (explicitLabelPrefix)
return explicitLabelPrefix;
const labelPrefix = run.match(/^(?:(?:the|your)[-_])?(?:account|acct|user|workspace|token|key)(?:[-_]+(?:id|identifier))?[-_]+/i)?.[0];
const prefix = labelPrefix ?? run.match(/^[A-Za-z][A-Za-z0-9]{0,12}[-_]+/)?.[0] ?? "";
// A UUID glued directly to a vendor word (org550e8400-...) has no safe
// prefix boundary. Preserving that whole digit-bearing fragment would defeat
// the atomic UUID replacement, so only retain alphabetic prefix segments.
return /\d/.test(prefix.slice(0, -1)) ? "" : prefix;
}
function preserveNaturalLanguagePrefixedRun(run) {
if (isExplicitIdentifierValue(run))
return false;
const prefix = run.match(/^(?:(?:the|your)[-_])?(?:account|user)[-_]+/i)?.[0];
if (!prefix)
return isNaturalLanguageAlphaRun(run);
return isNaturalLanguageAlphaRun(run.slice(prefix.length), "lexical-label-prose");
}
export function sanitizeDetail(raw, max = 200) {
const masked = redactTokens(raw)
// E-mail addresses FIRST: the local part may itself look like a bare id, but
// #2294's contract is to mask the whole address atomically.
.replace(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "<redacted>")
// A UUID anywhere in a token redacts the WHOLE token. Not the UUID, not the
// UUID-plus-a-recognised-prefix: the entire run of non-space characters it
// sits in. `\S` has no boundary to be defeated by, so segmented, glued, and
// multi-prefix forms cannot leave a partial UUID for the next rule.
.replace(/\S*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\S*/g, "<redacted>")
// Machine-labelled values are identifiers even when their alphabetic
// payload happens to be an English word. This also handles JSON-like
// `account_id: "..."` forms that are shorter than the bare-run threshold.
.replace(/((?:account|acct|user|workspace|token|key)(?:[_-]+(?:id|identifier)|\s+(?:id|identifier))\s*["']?\s*[:=]\s*["'([{]?\s*)([A-Za-z0-9]{10,}(?:[-_]+[A-Za-z0-9]+)*)(?=$|[^A-Za-z0-9])/gi, (_m, label) => `${label}<redacted>`)
// Prefixed and segmented opaque identifiers: org-…, org_…_…, cak-…, key_…,
// and long wrapper/value forms. The entire atom is matched before replacing
// it, so no later rule can turn a segmented value into a misleading partial.
// The underscore label form can wrap its value directly: account_identifier_[…].
.replace(/((?:account|acct|user|workspace|token|key)(?:[_-]+(?:id|identifier))[_-]+)([\[({])\s*[A-Za-z0-9]{10,}(?=\s*[\])}])/gi, (_m, label, opening) => `${label}${opening}<redacted>`)
.replace(/(^|[^A-Za-z0-9])(?!(?:account|acct|user|workspace|token|key)(?:[_-]+(?:id|identifier))(?=\s*["']?\s*[:=])|(?:account|acct|user|workspace|token|key)(?:[_-]+(?:id|identifier))[_-]+\s*[\[({])(((?:[A-Za-z][A-Za-z0-9]{0,12}[-_])+)[A-Za-z0-9]{10,}(?:[-_]+[A-Za-z0-9]+)*)(?=$|[^A-Za-z0-9])/g, (_m, boundary, run) => preserveNaturalLanguagePrefixedRun(run)
? `${boundary}${run}`
: `${boundary}${firstIdentifierPrefix(run)}<redacted>`)
// Long segmented values whose first segment is itself longer than a normal
// prefix (for example qavexidopulnertiskym_extra) are still one atom.
.replace(/(^|[^A-Za-z0-9])([A-Za-z0-9]{10,}(?:[-_]+[A-Za-z0-9]+)+)(?=$|[^A-Za-z0-9])/g, (_m, boundary, run) => preserveNaturalLanguagePrefixedRun(run) ? `${boundary}${run}` : `${boundary}<redacted>`)
// Bare long runs are identifiers unless the explicit prose table recognizes
// a bare prose boundary. Structured labels and wrappers override that table.
.replace(/\b[A-Za-z0-9]{20,}\b/g, (run, offset, input) => {
if (!hasExplicitAlphaIdentifierBoundary(input, offset) && isNaturalLanguageAlphaRun(run))
return run;
return "<redacted>";
});
return masked.replace(/\s+/g, " ").trim().slice(0, max);
}
/**
* Pull the human sentence out of a provider error body.
*
* OpenAI-compatible bodies nest it at `error.message`; some endpoints answer with
* bare text or HTML. Anything not JSON is used only if it is SHORT — a limiter's
* HTML block page has no sentence worth showing, and pasting its first 200
* characters is how the raw-dump problem started.
*/
function extractMessage(body) {
const trimmed = body.trim();
if (!trimmed)
return undefined;
try {
const parsed = JSON.parse(trimmed);
const err = parsed?.error;
const candidate = (err && typeof err === "object" ? err.message : undefined) ??
(typeof err === "string" ? err : undefined) ??
parsed?.message ??
parsed?.detail;
if (typeof candidate === "string" && candidate.trim())
return candidate.trim();
return undefined;
}
catch {
if (trimmed.length > 300 || /^\s*</.test(trimmed))
return undefined;
return trimmed;
}
}
/**
* Drop the provider's "please try again after N seconds" clause from the text
* shown to a user.
*
* That clause is an instruction to the CLIENT, and the client has already obeyed
* it — the wait was parsed out of this very sentence. Quoting it back reads as a
* contradiction next to the outcome ("did not recover after 3 attempts … please
* try again after 1 seconds") and tells the user to do something we did for them.
* What is left is the part they cannot act on any other way: WHICH limit was hit.
*/
function stripRetryHint(text) {
return text
.replace(/[,;.]?\s*(?:please\s+)?(?:try again|retry(?: again)?|wait)\b[^.\n]{0,40}?\b\d+(?:\.\d+)?\s*(?:ms|millis(?:econds?)?|s|secs?|seconds?|m|mins?|minutes?|h|hours?)\b\.?/gi, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Classify a response as a rate limit, or not at all.
*
* 429 ONLY. A 402/403 for billing is a different conversation with the user (and
* a different remedy), and folding it in here would have this module telling
* someone to "wait" for a limit that no amount of waiting clears.
*/
export function classifyRateLimit(status, headers, body) {
if (status !== 429)
return null;
const message = extractMessage(body);
const haystack = `${body} ${message ?? ""}`.toLowerCase();
const detail = message ? sanitizeDetail(stripRetryHint(message)) : undefined;
// Quota is decided BEFORE the window is read, and outranks it. Moonshot and
// OpenAI both answer an exhausted balance with a 429 that still carries a
// retry hint; honouring that hint would sleep, retry, and fail identically.
if (QUOTA_MARKERS.some((marker) => haystack.includes(marker))) {
return { kind: "quota", detail };
}
const waitMs = parseRetryAfter(headers.get("retry-after")) ??
parseResetDuration(headers.get("x-ratelimit-reset-requests") ?? "") ??
parseResetDuration(headers.get("x-ratelimit-reset-tokens") ?? "") ??
parseResetDuration(headers.get("x-ratelimit-reset") ?? "") ??
(message ? parseWaitFromProse(message) : null);
if (waitMs === null)
return { kind: "unknown", detail };
return { kind: "retryable", retryAfterMs: waitMs, detail };
}
/** "1s" / "20s" / "2m30s" — for a sentence a person reads, not a machine. */
export function humanWait(ms) {
if (ms < 1000)
return `${Math.max(1, Math.round(ms))}ms`;
const secs = Math.round(ms / 1000);
if (secs < 60)
return `${secs}s`;
const m = Math.floor(secs / 60);
const s = secs % 60;
return s ? `${m}m${s}s` : `${m}m`;
}
/** The line shown while waiting out a window (announced waits only). */
export function retryingNotice(model, waitMs, verdict) {
const because = verdict.detail ? ` (${verdict.detail})` : "";
return `⏳ ${model} is rate limited${because}. Retrying in ${humanWait(waitMs)}…`;
}
/** The sentence the turn ends on when waiting is not an option, or did not work. */
export function gaveUpNotice(model, verdict, attempts,
/** Whether the provider's response body could be read at all (#796): "it did
* not say how long to wait" is a claim about what arrived, and must not be
* made about a body we never managed to read. */
bodyReadable = true) {
const because = verdict.detail ? ` — ${verdict.detail}` : "";
if (verdict.kind === "quota") {
return (`⚠️ ${model} rejected the request: the account is out of quota or credit${because}. ` +
`Retrying will not help — top up or change plan with the provider, or switch models from the composer picker. ` +
`The turn stopped part-way, so if it had already started changing the graph, check the canvas before you resend.`);
}
if (attempts > 0) {
return (`⚠️ ${model} is rate limited and did not recover after ${attempts} ${attempts === 1 ? "attempt" : "attempts"}${because}. ` +
`Try again in a moment or switch models from the composer picker — but if the turn had already started changing the graph, check the canvas before re-sending, because re-sending runs those steps again.`);
}
const why = verdict.kind !== "unknown"
? ""
: bodyReadable
? "The provider did not say how long to wait, so the request was not retried automatically. "
: "Its response could not be read, so there was no wait to honour and the request was not retried automatically. ";
return (`⚠️ ${model} hit its rate limit${because}. ${why}` +
`Try again in a moment or switch models from the composer picker — but if the turn had already started changing the graph, check the canvas before re-sending, because re-sending runs those steps again.`);
}
/** Abort-aware sleep: an interrupt during a backoff must not sit out the window. */
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(new Error("aborted"));
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Send a request, waiting out 429s that name a bounded window.
*
* Yields `rate_limit` events for waits worth announcing and RETURNS the response
* to the caller, which keeps its own handling for every other status — a 500 or a
* 400 reaches the existing error path untouched, so this changes one status and
* nothing else.
*
* BODY OWNERSHIP: the body is read here ONLY on a 429 (to classify it). A
* response returned to the caller therefore always has its body unread, which is
* what the callers' `await res.text()` error paths assume. A 429 we give up on
* never comes back as a response — it is thrown as a RateLimitError whose message
* is already the finished sentence — so no caller is ever handed a response whose
* body this consumed.
*/
export async function* sendWithRateLimitRetry(send, opts) {
const maxRetries = rateLimitMaxRetries();
const budgetMs = rateLimitMaxTotalWaitMs();
let spentMs = 0;
let attempts = 0;
for (;;) {
const res = await send();
if (res.status !== 429)
return res;
// 429 — from here the body belongs to us.
//
// An UNREADABLE body is not an empty one (#796). Both leave us without the
// provider's explanation, but they support different sentences: "it did not
// say how long to wait" is a claim about what the provider sent, and making
// it about a body we failed to read would be a confident wrong answer. The
// headers survive either way, so a Retry-After still yields a real retry.
const read = await res
.text()
.then((text) => ({ readable: true, text }))
.catch(() => ({ readable: false, text: "" }));
const verdict = classifyRateLimit(res.status, res.headers, read.text) ?? { kind: "unknown" };
const waitMs = verdict.retryAfterMs;
const outOfAttempts = attempts >= maxRetries;
const overBudget = typeof waitMs === "number" && spentMs + waitMs > budgetMs;
const retryable = verdict.kind === "retryable" && typeof waitMs === "number";
if (!retryable || outOfAttempts || overBudget) {
const why = verdict.kind === "quota"
? "quota exhausted"
: outOfAttempts
? `out of retries after ${attempts}`
: overBudget
? `window ${humanWait(waitMs ?? 0)} would exceed the ${humanWait(budgetMs)} budget`
: read.readable
? "no retry window given"
: "response body unreadable";
logger.warn(`[${opts.label}] 429 from ${opts.model}, not retrying (${why})`);
throw new RateLimitError(gaveUpNotice(opts.model, verdict, attempts, read.readable), verdict.kind, waitMs);
}
attempts += 1;
spentMs += waitMs;
logger.info(`[${opts.label}] 429 from ${opts.model} — waiting ${humanWait(waitMs)} (attempt ${attempts}/${maxRetries}, ${humanWait(spentMs)} of ${humanWait(budgetMs)} spent)`);
if (waitMs >= rateLimitAnnounceMs()) {
yield {
type: "rate_limit",
kind: verdict.kind,
resetsAt: Date.now() + waitMs,
retryInMs: waitMs,
message: retryingNotice(opts.model, waitMs, verdict),
};
}
// The turn watchdog must keep seeing life: this IS the request making
// progress, and a silent 20s would otherwise read like a stalled backend.
opts.onActivity?.();
await sleep(waitMs, opts.signal);
opts.onActivity?.();
}
}
//# sourceMappingURL=rate-limit.js.map