UNPKG

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),

253 lines • 11.5 kB
// Transient-failure classification + backoff for the local streaming download // path (#470, #817). // // WHY THIS EXISTS // A multi-GB transfer over a modest link WILL be interrupted — a CDN drop, a // proxy reset, an undici `terminated`. Before this, ONE such blip terminated the // whole job permanently ("failed: terminated"), and the caller's only recourse // was to re-issue `download_model` by hand. The bytes on disk were not lost (the // `.partial` + `.etag` sidecar machinery from #343/#467 preserves and safely // resumes them), but a human/agent had to notice and act. This module supplies // the policy that lets the SAME call recover by itself: retry with backoff, // resuming from the preserved partial each attempt. // // FAIL-SAFE BY CONSTRUCTION // `classifyDownloadFailure` defaults to NOT retryable. Only an explicitly // enumerated transport/transient signal opts an error in. An unrecognized error // therefore behaves EXACTLY as it did before this module existed (surface it, // once). That direction of default matters: a wrongly-retried integrity refusal // would re-run a corruption path several times and burn bandwidth, while a // wrongly-NOT-retried transport blip merely reproduces today's behavior. // // NEVER RETRIED (deliberately absent from the transient set): // - user cancellation — the caller's abort is checked by the loop itself, // before classification, so a cancel can never be "recovered from"; // - 4xx other than 408/429 — auth/gating/not-found; a retry cannot fix them // and, per #470's own report, an instant re-request is what earned a 403; // - #473 non-model payload rejections — an HTML/JSON auth page is a // configuration problem, not a blip; // - the #343/#467 resume/integrity refusals (bad Content-Range, size // disagreement, oversized body) — those mean the SERVER is misbehaving or // the object changed; hammering it is not a remedy. import { logger } from "../utils/logger.js"; /** * Node/undici error codes that denote a TRANSPORT interruption — the connection * died, not the request being wrong. Every one of these leaves the bytes already * written to the `.partial` intact and range-resumable. */ const TRANSIENT_CODES = new Set([ "ECONNRESET", "ECONNABORTED", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "ENETUNREACH", "ENETRESET", "ENETDOWN", "EHOSTUNREACH", "EAI_AGAIN", "ERR_STREAM_PREMATURE_CLOSE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_RESPONSE_STATUS_CODE", ]); /** * HTTP statuses worth another attempt. 408/429 are the server ASKING for one; * 5xx is the server failing transiently. Everything else 4xx is a request the * retry cannot change. */ const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]); /** * Message fragments for transport failures that arrive with NO usable code. * undici surfaces a mid-body socket death as a bare `TypeError: terminated` * whose code lives (if anywhere) on a nested cause — this is the exact string * #470 reported. * * Kept deliberately SHORT and specific. It must not include the wording of * fetchOrThrow's wrapper ("…failed at the network layer: …"), which is applied * to EVERY thrown fetch — that would make an expired TLS certificate, a bad * proxy, or an aborted request all look transient, i.e. it would invert this * module's fail-safe default for the entire class of fetch failures. Those cases * carry real codes (CERT_HAS_EXPIRED, ENOTFOUND, ECONNRESET, …) and are decided * by the code list above; a fetch failure with NO code and none of these phrases * is not recognised, and therefore not retried. */ const TRANSIENT_MESSAGE_FRAGMENTS = [ "terminated", "socket hang up", "other side closed", "premature close", ]; /** Walk an error's `cause` chain collecting every `code` seen. undici nests the * real `ECONNRESET`/`UND_ERR_SOCKET` one or two levels under a bare TypeError. */ function codesOf(err) { const codes = []; const seen = new Set(); let cur = err; while (cur instanceof Error && !seen.has(cur)) { seen.add(cur); const c = cur.code; if (typeof c === "string") codes.push(c); cur = cur.cause; } // A ModelError carries the unwrapped fetch code on `details.code` (fetchOrThrow). const details = err?.details; if (details && typeof details.code === "string") codes.push(details.code); return codes; } /** Every message in the cause chain, lower-cased and joined. */ function messagesOf(err) { const parts = []; const seen = new Set(); let cur = err; while (cur instanceof Error && !seen.has(cur)) { seen.add(cur); if (cur.message) parts.push(cur.message); cur = cur.cause; } if (parts.length === 0) parts.push(String(err)); return parts.join(" | ").toLowerCase(); } /** * Decide whether a failed download attempt may be retried IN THE SAME CALL. * * Defaults to `false`. The caller must ALSO have established that the user did * not cancel — this function deliberately does not inspect abort state, because * "the user cancelled" is not a property of the error (an AbortError is raised * by our own stall watchdog too, and THAT one is retryable). Keeping the two * concerns apart is what stops a cancel from being retried into a completion. */ export function classifyDownloadFailure(err) { const details = err?.details; // 1. An explicit contract from the raising code wins over any heuristic — in // BOTH directions, so a site that knows the partial is poison can veto. if (details && typeof details.retryable === "boolean") { return { retryable: details.retryable, reason: details.retryable ? "the transfer was interrupted mid-stream and the partial file is still resumable" : "the failure is not recoverable by retrying", }; } // 2. An HTTP status we captured. A DEFINITE non-transient status must LOSE to // nothing below it: return here either way so a 403's prose ("Forbidden") // can never fall through to the message-fragment test. const status = typeof details?.status === "number" ? details.status : undefined; if (status !== undefined) { return TRANSIENT_STATUSES.has(status) ? { retryable: true, reason: `the host answered ${status}, which is a transient/back-off status` } : { retryable: false, reason: `the host answered ${status}, which a retry cannot change` }; } // 3. A transport error code anywhere in the cause chain. for (const code of codesOf(err)) { if (TRANSIENT_CODES.has(code)) { return { retryable: true, reason: `the connection failed with ${code}` }; } } // 4. Last resort: a transport failure that arrived with no code at all. const message = messagesOf(err); for (const fragment of TRANSIENT_MESSAGE_FRAGMENTS) { if (message.includes(fragment)) { return { retryable: true, reason: `the connection dropped mid-transfer ("${fragment}")` }; } } return { retryable: false, reason: "the failure is not a recognized transport interruption" }; } /** * Tunable retry/stall policy. Defaults are production values; tests override via * {@link setDownloadRetryPolicyForTests} so the suite never actually sleeps. */ const policy = { /** TOTAL attempts per download (1 = the pre-#470 behavior, no retry). */ maxAttempts: 5, /** First backoff delay; doubles each attempt up to `backoffCapMs`. #470 traced * a 403 to an INSTANT re-request after an abort, so the first wait is real. */ backoffBaseMs: 2_000, backoffCapMs: 30_000, /** * How long an attempt may go with ZERO bytes arriving before it is abandoned * and retried (#470's "no growth for ~30 min while still listed in-flight", * and the bounded-attempt half of #817). This is a STALL bound, not a total * bound: a slow-but-alive 20 GB transfer is never interrupted by it, because * every chunk resets the clock. A total-duration cap — the 600s in #817 — is * the wrong shape; it kills healthy large transfers and cannot distinguish * them from wedged ones. */ stallTimeoutMs: 120_000, }; /** @internal — test hook to shrink retry/stall timings; not part of the tool API. */ export function setDownloadRetryPolicyForTests(overrides) { Object.assign(policy, overrides); } function hasEnv(name) { const raw = process.env[name]; return raw !== undefined && raw.trim() !== ""; } function envInt(name, fallback, min, max) { const raw = process.env[name]; if (raw === undefined || raw.trim() === "") return fallback; const n = Number(raw); if (!Number.isFinite(n)) { logger.warn(`Ignoring ${name}="${raw}" — not a number; using ${fallback}.`); return fallback; } return Math.min(max, Math.max(min, Math.round(n))); } /** The live policy, with env overrides applied. Read per-download so a setting * change takes effect without a restart. */ export function downloadRetryPolicy() { return { // 1 disables retry entirely — an explicit, documented escape hatch. maxAttempts: envInt("COMFYUI_DOWNLOAD_MAX_ATTEMPTS", policy.maxAttempts, 1, 20), backoffBaseMs: policy.backoffBaseMs, backoffCapMs: policy.backoffCapMs, // 0 disables the stall watchdog (the pre-#470 "wait forever" behavior). // The env var is in SECONDS but the policy is in MILLISECONDS, so the // fallback is taken in ms and only a PRESENT env value is scaled — routing // the default through a seconds round-trip would silently floor any // sub-second policy (which is how tests configure a fast watchdog) to zero, // i.e. to "no watchdog at all". stallTimeoutMs: hasEnv("COMFYUI_DOWNLOAD_STALL_TIMEOUT_S") ? envInt("COMFYUI_DOWNLOAD_STALL_TIMEOUT_S", 0, 0, 86_400) * 1000 : policy.stallTimeoutMs, }; } /** Exponential backoff for `attempt` (1-based: the wait BEFORE attempt 2 is * `attempt = 1`), capped. Deterministic — the caller adds jitter if it wants * it; tests assert the schedule exactly. */ export function backoffDelayMs(attempt, p = downloadRetryPolicy()) { const raw = p.backoffBaseMs * 2 ** Math.max(0, attempt - 1); return Math.min(p.backoffCapMs, raw); } /** * Sleep that wakes EARLY (and rejects) if `signal` aborts. A cancel issued * during a backoff wait must stop the download then and there — not up to 30 * seconds later when the timer happens to fire. */ export function abortableDelay(ms, signal) { if (ms <= 0) return Promise.resolve(); return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException("The download was cancelled.", "AbortError")); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); reject(new DOMException("The download was cancelled.", "AbortError")); } signal?.addEventListener("abort", onAbort, { once: true }); }); } //# sourceMappingURL=download-retry.js.map