fix(cli): recover transient publish failures - #3087
Conversation
vanceingalls
left a comment
There was a problem hiding this comment.
R1 adversarial review — fix(cli): recover transient publish failures
Verdict: APPROVE
Design lens
The retry policy is scoped correctly. fetchForPublish catches only exceptions from fetch() itself — in Node's undici that means transport-level failures (DNS, TCP, TLS, AbortError). HTTP 4xx/5xx come back as Response objects and continue to flow through readErrorMessage unchanged, so this PR does not silently retry user errors or backend HTTP errors (per feedback_widened_retry_policy_audit.md — the widened-taxonomy audit passes: transport-only, bounded at 2).
Idempotency is preserved:
prepareretry re-mints a freshupload_key(previous one is orphaned; server TTL cleans up) — safe.- S3 PUT retry hits the same signed URL/key — S3 PUT is idempotent on a stable key — safe.
completedeliberately kept single-shot (packages/cli/src/utils/publishProject.ts:627-644, attempts=1). PR body cites the "backend consumes and deletes the staged object" reason; this is the correct call — retrying a completed finalize would risk duplicating an anonymous project.- Legacy direct-publish path (
:543-556) also stays single-shot — same reasoning applied uniformly across the "may have server-side committed" surfaces (legacy multi-role endpoint lens: role-appropriate retry policy per stage).
Cancellation chain is clean: createInit factory is invoked per attempt, so each retry gets a fresh AbortSignal.timeout(...). Signals are never reused across attempts. No external AbortController is threaded in, so no user-cancel-mid-retry hazard.
Motivation ↔ fix fit passes (per feedback_ground_truth_first_before_pr_open.md): user's opaque fetch failed is undici's TypeError('fetch failed'); the new tests explicitly cover EAI_AGAIN, ECONNRESET, ENETUNREACH, and the HTTPS_PROXY-without-NODE_USE_ENV_PROXY misconfig — that IS the reported class.
Test hygiene passes (per feedback_test_asserts_guard_semantics_not_presence.md): tests verify behavior, not presence — retry count, URL identity between attempts (mock.calls[1] vs mock.calls[2]), stage-named error text, rejects.not.toThrow("do-not-print") for signed-URL cred leakage, and that finalize does NOT retry (only 1 mockRejectedValueOnce, expects it to bubble).
Secret hygiene passes: redactUrlQuery strips ?[query] from any URL in the error text, and there's a dedicated test asserting the X-Amz-Signature=do-not-print value never appears in the surfaced error.
Nits (non-blocker)
-
No backoff/jitter between attempts (
packages/cli/src/utils/publishProject.ts:211-222) — retry is immediate. For transient DNS (EAI_AGAIN), an immediate second query is likely to hit the same failed resolver state. A small jittered delay (100-300ms) would meaningfully raise the recovery rate for the exact class this is designed to handle. Bounded at 2 attempts so the current shape is safe; just leaving performance on the table. -
Unreachable fallthrough (
packages/cli/src/utils/publishProject.ts:223) —throw new Error(failureStage)after theforloop can never execute becauseattempts >= 1by construction and the final attempt always either returns or throws. Dead but harmless; TS control-flow needs it for the return type. -
errorCodescope (packages/cli/src/utils/publishProject.ts:170-173) — only inspects.code. Undici does surface.codeon.causeso the common case is covered, buterrnoandsyscallare also standard fields on Node system errors and could be worth appending for messages likeENOTFOUNDthat come through libc-flavored paths. Minor. -
proxySupportHint()fires unconditionally (packages/cli/src/utils/publishProject.ts:179-190) — appended to everydescribeFetchFailureoutput wheneverHTTPS_PROXYis set andNODE_USE_ENV_PROXYisn't. Factually correct (the user's Node fetch is indeed ignoring their proxy), but if the true failure is unrelated to the proxy the hint may misdirect. Consider gating it toEAI_*/ECONNREFUSED/ETIMEDOUTcodes where proxy misconfig is the plausible root cause.
CI
Head SHA f2d6ce3. All required checks green (Test, Typecheck, Lint, Build, CLI smoke (required), Fallow audit, SDK: unit + contract + smoke, Preview parity, preview-regression, regression, Render on windows-latest, Semantic PR title, Format, File size check, CLI: npx shim for ubuntu/macos/windows, CodeQL analyses). Tests on windows-latest still in progress at review time; its ubuntu counterpart passed and the same tests ran green on Render on windows-latest, so no risk of divergence.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at f2d6ce32.
Small, well-scoped fix. Nothing blocking from my side — leaving as a comment.
What lands cleanly. The retry policy is a positive-list at the call-site, not an error classifier — PUBLISH_TRANSPORT_ATTEMPTS is passed only to uploadArchiveToPresignedUrl (:568) and to the staged-prepare call (:595), while publishProjectArchiveDirect (:543) and the staged /publish/complete (:627) both default to attempts = 1. That's the right shape here: the "which stages retry" decision lives in code you can grep, not in a shouldRetry(err) table that has to be kept in sync with Node's evolving fetch error taxonomy. The non-retry of /publish/complete has the exact "backend consumes and deletes the staged object, so blindly retrying it could duplicate an anonymous project or retry a publish that already committed" rationale in the body — the correct call, and the test at publishProject.test.ts:838 pins it (one rejection → immediate throw, no second call). S3 PUT is idempotent under key-replace so 2× is safe; prepare mints a fresh presigned URL so multiple prepares just leave one used and the others unclaimed (server-side ephemeral-state accumulation is not this PR's problem).
Load-bearing observability details:
describeFetchFailure(:194-203) extracts the code from BOTHerror.causeand the outer error, and skips the cause message when it duplicates the outer (causeMessage && causeMessage !== message). So"fetch failed"with cause"fetch failed"doesn't produce"fetch failed (fetch failed)"— nice touch.redactUrlQueryregex/(https?:\/\/[^\s?]+)\?[^\s]+/gu(:175-177) is light-touch — keeps the host+path visible (which is the diagnostic value: "the S3 hostname is unreachable") while stripping the Sig-V4 params. Test at:781explicitly assertsnot.toThrow("do-not-print")against a canary secret, which is the kind of test that catches a future "we accidentally leaked X-Amz-Signature to the terminal on a customer's zoom screenshare" regression.proxySupportHint(:179-192) checks bothprocess.execArgv(child spawned with--use-env-proxy) andNODE_OPTIONS(env-configured). TheNODE_OPTIONSparse issplit(/\s+/u).includes("--use-env-proxy")— exact token match, so a hypotheticalNODE_OPTIONS="--use-env-proxy-something-else"wouldn't false-positive. Good.createInitfactory pattern (:207) so eachfetchattempt gets a freshAbortSignal.timeoutand a freshbody. Without that, the second attempt would inherit an already-fired timeout signal and (for the S3 PUT) an already-consumed Blob. Easy trap to miss.
A few observations, non-blockers:
- Zero backoff between attempts (
:211-222). For DNS/TCP transient failures, an immediate retry can hit the same negative-cache TTL or the same brief network glitch — a small delay (100-250ms) tends to convert marginal successes without noticeably lengthening the failure path (2 attempts × 100ms = 200ms added latency on the "definitely-broken" path). Worth considering as a follow-up; the current 2-attempt no-backoff shape is defensible and simpler and doesn't needuseFakeTimersin tests. - Timeouts are also retried.
AbortSignal.timeoutfirings surface asAbortError, which the negative-list catch retries — so on prepare we get2 × 30s = 60sworst-case wait, and on the S3 PUT2 × uploadTimeoutMs. For a 500MB archive at the 500kbps floor that's2 × ~1000s ≈ 33minbefore the user sees the diagnostic. Probably intentional (the alternative is to distinguishAbortErrorfromTypeErrorin the catch and skip retry on timeouts), but worth naming — if #2840-style rollout instrumentation later shows a cohort waiting 30min on legitimately-dead servers, that's the loop to break. Non-blocker; today's shape is fine. - Unreachable throw at
:223. The loop always eitherreturns orthrows inside theif (attempt === attempts)branch, so the finalthrow new Error(failureStage)can never fire. TypeScript's control-flow analysis doesn't know that; keeping it as-is is fine. A tiny signal to a future reader would bethrow new Error("unreachable: fetchForPublish loop exited without returning")or an assertion — bikeshed, ignore if you want. - Live production verification carries weight. PR body says
hyperframes@0.7.98was exercised end-to-end AND a forced unreachable API reproduced the oldfetch failedpath plus the new stage-specific output. That's the pair of tests the unit suite can't do on its own for a network-boundary CLI. The screenshot on the Slack thread (Dinesh Kumar'sPublish failed / fetch failedoutput) is the exact "totally blind" case this closes.
Test coverage at publishProject.test.ts:739-856 reads cleanly — retry-once-succeed on upload (:739), retry-cap-exhausted-throws with redaction pinned (:763), proxy-hint in error (:788), retry-once-succeed on prepare (:810), complete-does-not-retry (:838). Five tests for a ~75-line fix, each nailing a specific contract. Nothing missing that I can see.
LGTM from my side — left as a comment.
|
Folded the full review sweep into
TDD evidence: four expected failures before the implementation; then focused 37/37, full CLI 2,481 passed / 2 skipped, typecheck, format, lint, and diff check all green. Please refresh the approval on exact head |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at dd629697d.
All three of the R1 observations folded cleanly. Delta-verifying each:
Backoff. PUBLISH_RETRY_DELAY_MS = 200 + waitBeforePublishRetry() (:230-232) called between attempts. Test at :767 uses vi.useFakeTimers() with a three-step timeline — assert 2 calls at t=0 (prep + first-attempt), still 2 at t=199, jump to 4 at t=200 (retry proceeded through S3 PUT + finalize). That's the tightest way to pin a "waited exactly N ms" contract; the alternative (spy on setTimeout) would test the mechanism instead of the observable behavior. 200ms is a defensible starting point — long enough that DNS negative caches likely dropped, short enough to be imperceptible on the "definitely-broken" path.
Timeouts short-circuit. isRequestTimeout (:214-218) catches both DOMException names that AbortSignal.timeout can surface as (TimeoutError and AbortError), and the retry loop breaks out immediately without sleeping (:239). Test at :883 pins the shape — one fetch call, error message Failed to prepare project upload: The operation was aborted due to timeout (no "after N attempts" suffix because attemptsMade === 1). Right call — a timeout means we already spent timeoutMs waiting; retrying would just re-consume that budget on the same failure.
Throw is now reachable. The old inside-catch throw is replaced by outside-the-loop throw at :245-247, driven by lastError and attemptsMade tracked through the loop. TypeScript can now see the throw is the loop's only exit-with-error, and the RangeError bounds check at :225 (attempts < 1) closes the defensive gap. Nice.
Bonuses I didn't ask for:
systemErrorMetadata(:171-180) now capturessyscallanderrnoalongsidecode, deduplicated across cause + outer. Test at:809pins the resulting shape —(EAI_AGAIN, syscall=getaddrinfo, errno=-3001: getaddrinfo EAI_AGAIN s3.example.com)— which is the diagnostic uplift that turns "some DNS thing failed" into "getaddrinfo call, EAI_AGAIN, errno -3001" that a support engineer can grep against a distro'snetdb.hif it comes to that.- Proxy-hint phrasing tightened (
:196) — "set but ignored by Node fetch; if this network requires them" replaces "disabled". More accurate: the runtime isn't broken, it just doesn't opt-in to proxies without the flag.
Test coverage delta at publishProject.test.ts is proportional — two new tests (:767 for the backoff timing, :883 for the no-retry-on-timeout) plus the exhausted-retry test's expected message updated to reflect the new metadata format. networkFailure helper extended with an optional metadata param, and only the tests that exercise metadata pass it — no shotgun changes.
LGTM from my side — clean fold.
vanceingalls
left a comment
There was a problem hiding this comment.
R2 delta-verify — fix(cli): recover transient publish failures
Verdict: APPROVE
Delta reviewed: f2d6ce3 → dd629697d. Only packages/cli/src/utils/publishProject.ts and its test file changed. Standing R1 APPROVE upgraded to R2 APPROVE.
R1 nits — status
-
Backoff between attempts — addressed. New
PUBLISH_RETRY_DELAY_MS = 200+waitBeforePublishRetry()inserted between attempts insidefetchForPublish. Total wait bounded (single retry → 200ms max additional latency). No jitter, but this is a per-user CLI publish path — there's no fleet of concurrent retriers to synchronize, so a fixed constant is fine. New testwaits briefly before retrying a transport failurepins the 200ms boundary with fake-timer assertions at 199ms (2 calls) → 1ms later (4 calls). Semantic, not presence. -
Unreachable
throwafter the loop — addressed by structural refactor. The loop nowbreaks on last attempt (or on timeout, see below), setslastError/attemptsMade, and the throw lives after the loop as the terminal path. Dead code gone; the RangeError guard onattempts < 1is a nice-to-have addition. -
errno/syscallon system errors — addressed.errorCode()replaced withsystemErrorMetadata()which readscode,syscall,errno. No PII surface: these are Node system-error primitives (getaddrinfo,-3001,EAI_AGAIN) — no paths, no user input, no tokens. Dedup filter across cause + top-level. Output still runs throughredactUrlQuery. Existing PII-leak assertion (.not.toThrow("do-not-print")) still holds — verified in the updated test that asserts the exact new format(EAI_AGAIN, syscall=getaddrinfo, errno=-3001: getaddrinfo EAI_AGAIN s3.example.com). -
Proxy hint gating — addressed via wording, not error-class gating.
proxySupportHint()still fires wheneverHTTPS_PROXYis set, but the copy is now hedged: "Proxy variables are set but ignored by Node fetch; if this network requires them, retry with NODE_USE_ENV_PROXY=1". Not the class-gate I sketched, but functionally equivalent — the user is no longer told definitively that the failure is proxy-related. Acceptable resolution for a nit.
Adversarial delta pass
-
New retry classification (
isRequestTimeout): DOMException withname === "TimeoutError" || "AbortError"now short-circuits the retry loop. Behavior change: previously a timeout on attempt 1 would burn a retry; now it fails fast. Correct — retrying a timeout with the same timeout budget is pathological. New testdoes not retry a request that reached its timeoutpins this to 1 fetch call. AbortError also short-circuits — safe because this code owns the AbortSignal (fromAbortSignal.timeout), so any AbortError is a self-imposed timeout, not an external abort. -
Retry-transient-only invariant: retry surface is still bounded by
PUBLISH_TRANSPORT_ATTEMPTS = 2and now further narrowed byisRequestTimeout. Nothing widens the retry class. -
Idempotency: retry scope unchanged (was 2 attempts before, still is). The prepare/upload/finalize endpoints' idempotency assumptions are the same as at R1.
-
Test drift check: no assert-presence regression. All new / modified tests pin exact message strings (with the new
syscall=/errno=metadata), exact fetch-call counts, exact timer-advance boundaries. -
Cross-platform:
DOMException,AbortSignal.timeout,setTimeoutall universal in Node 22.syscall/errnoare Node system-error fields on all platforms — Windows will emit different codes (ETIMEDOUT,ENETUNREACHvariants) but the metadata plumbing is agnostic.
CI
Green on all required checks except Tests on windows-latest (pending — non-required parity to the passing Test job). Non-blocking.
— Via
Summary
hyperframes publishnow recovers from a transient failure while preparing the staged upload or sending the archive to S3, instead of immediately ending with the opaquefetch failed. The retry waits briefly so a DNS/socket interruption can clear, while request timeouts stay single-shot to avoid doubling the worst-case wait for large archives.If a request still fails, the CLI names the failed stage, preserves the underlying network code, syscall, errno, and message, redacts presigned URL credentials, and explains how to enable Node's environment-proxy support when proxy variables are present. The proxy note is explicitly conditional so it does not claim the proxy caused an unrelated failure.
The completion request deliberately remains single-shot: the backend consumes and deletes the staged object, so blindly retrying it could duplicate an anonymous project or retry a publish that already committed. HTTP errors also remain non-retryable, and the legacy direct-upload fallback behavior is unchanged.
Root cause
Three different network boundaries were collapsed into the same top-level
fetch failedmessage because the CLI discardedError.cause. Production checks returned 200 from the HeyGen prepare and complete endpoints and from the presigned S3 PUT, while a forced DNS failure reproduced the reported output exactly. This points to a client transport interruption—not a current backend application error—and makes bounded recovery plus stage-specific diagnostics the appropriate fix.Test plan
publishProject.test.ts: 37/37oxfmt,oxlint,git diff --checkhyperframes@0.7.98production publish exercised prepare → S3 PUT → complete successfullyfetch failedpath and verified the new stage/cause output