Skip to content

feat(seed-utils): retry transient Upstash failures in atomicPublish#3650

Merged
koala73 merged 2 commits into
mainfrom
feat/seed-utils-publish-retry
May 10, 2026
Merged

feat(seed-utils): retry transient Upstash failures in atomicPublish#3650
koala73 merged 2 commits into
mainfrom
feat/seed-utils-publish-retry

Conversation

@koala73

@koala73 koala73 commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

Wraps atomicPublish's 3-call publish unit (staging SET → canonical SET → staging DEL) in withRetry so a single transient Upstash REST timeout no longer kills a multi-minute seeder run. Also tags redisCommand errors with nonRetryable: true for permanent 4xx and retryAfterMs for 429 — so the existing withRetry helper makes the right backoff decision.

Why now

WM 2026-05-10 incident: seed-forecasts cron failed with a Redis SET timeout on a 0.17MB payload (well under any limit) after completing the entire 3-minute run — LLM calls, 42 forecasts filtered, full publish payload built. Stack trace:

DOMException [TimeoutError]: The operation was aborted due to timeout
  at async redisCommand (_seed-utils.mjs:140:16)
  at async atomicPublish (_seed-utils.mjs:254:5)
  at async runSeed (_seed-utils.mjs:1236:27)

Cron just waited an hour for the next tick. forecasts + marketImplications stayed STALE_SEED for ~3 hours.

R2 timeouts and OpenRouter LLM failures earlier in the same run were caught and degraded gracefully (fallback narratives applied). The ONLY fragility was the publish step itself.

Companion to PR #3649

PR #3649 caps digest:replay-log:v1:* lists at 100K entries to prevent Upstash 500MB overages that were back-pressuring these timeouts. Together:

Changes

1. redisCommand — error tagging (no retry on its own)

   if (!resp.ok) {
     const text = await resp.text().catch(() => '');
-    throw new Error(`Redis command failed: HTTP ${resp.status} — ${text.slice(0, 200)}`);
+    const err = new Error(`Redis command failed: HTTP ${resp.status} — ${text.slice(0, 200)}`);
+    if (PERMANENT_4XX_STATUSES.has(resp.status)) {
+      err.nonRetryable = true;
+    } else if (resp.status === 429) {
+      const retryAfterMs = parseRetryAfterMs(resp.headers.get('retry-after'));
+      if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
+    }
+    err.httpStatus = resp.status;
+    throw err;
   }

redisCommand itself stays single-attempt — auto-retrying every redisCommand caller (e.g. acquireLock with NX semantics) would change behavior subtly. Only callers that opt in via withRetry get the retry behavior.

2. atomicPublish — wrap the body in withRetry

-  const runId = String(Date.now());
-  const stagingKey = `${canonicalKey}:staging:${runId}`;
   ...
-  await redisSet(url, token, stagingKey, payloadValue, 300);
-  if (ttlSeconds) await redisCommand(url, token, ['SET', canonicalKey, payload, 'EX', ttlSeconds]);
-  else await redisCommand(url, token, ['SET', canonicalKey, payload]);
-  await redisDel(url, token, stagingKey).catch(() => {});
-  return { payloadBytes, recordCount: ... };
+  return await withRetry(
+    async () => {
+      const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+      const stagingKey = `${canonicalKey}:staging:${runId}`;
+      await redisSet(url, token, stagingKey, payloadValue, 300);
+      if (ttlSeconds) await redisCommand(url, token, ['SET', canonicalKey, payload, 'EX', ttlSeconds]);
+      else await redisCommand(url, token, ['SET', canonicalKey, payload]);
+      await redisDel(url, token, stagingKey).catch(() => {});
+      return { payloadBytes, recordCount: ... };
+    },
+    2,    // 2 retries (3 attempts total)
+    1000, // 1s base; exponential → 1s, 2s, 4s ≈ 7s worst case
+  );

Each attempt re-stages with a fresh runId so a previous attempt's staging key (if the SET landed server-side but the response was lost) doesn't shadow the retry. The 5-min staging TTL cleans up orphans automatically.

Retry decision matrix

Failure mode Tagged withRetry behavior
Timeout (DOMException) (none) Retries with exponential backoff
5xx (502, 503, 504) (none) Retries with exponential backoff
Network error (ECONNRESET, fetch failed, undici) (none, but isTransientRedisError matches) Retries with exponential backoff
429 (rate limit) retryAfterMs from header Waits max(baseWait, retryAfterMs), then retries
401, 403, 404, 410, 422, 451 (permanent) nonRetryable: true Exits loop in ~10ms
400 (bad request) nonRetryable: true Exits loop

408 and 429 are NOT in PERMANENT_4XX_STATUSES (per PR #3635 review) — they're explicit "try again" signals.

Test plan

  • node --test tests/seed-utils-with-retry.test.mjs — 17/17 pass
    • 5 new tests for atomicPublish retry behavior:
      • happy path (no retry, 3 fetch calls)
      • transient 503 → retry → success (4 calls, ≥1s elapsed for 1s backoff)
      • permanent 401 → fast-fail, no retry (1 call, <500ms)
      • persistent 503 → exhausts 3 attempts then throws
      • 429 with Retry-After: 2 → wait ≥2s before retry
    • 12 existing tests for withRetry, parseRetryAfterMs, PERMANENT_4XX_STATUSES, isTransientRedisError all unchanged
  • node --test tests/seed-utils*.test.mjs — 54/54 pass (full seed-utils surface)
  • Post-merge: monitor seed-forecasts (and other long-running seeders) for crashes on Redis timeouts. Expectation: transient timeouts retry silently; only persistent failures (3+ attempts) surface

What this DOES NOT change

  • Permanent failures (auth, payload-too-large) still abort fast — no useless backoff
  • The 5MB MAX_PAYLOAD_BYTES check stays at the top of atomicPublish — payload validation runs once, not per attempt
  • acquireLock's single-attempt behavior unchanged — wrap at call site (acquireLockSafely:201) handles retry semantics specifically for NX
  • Other Redis read paths (redisGet) unchanged — their callers can opt in via withRetry as needed

WM 2026-05-10 incident: seed-forecasts cron failed with a Redis SET
timeout on a 0.17MB payload after completing all expensive work
(LLM calls, 42 forecasts filtered, payload built). Stack trace:

  DOMException [TimeoutError]: The operation was aborted due to timeout
    at async redisCommand (_seed-utils.mjs:140:16)
    at async atomicPublish (_seed-utils.mjs:254:5)
    at async runSeed (_seed-utils.mjs:1236:27)

A single transient Upstash REST timeout at the FINAL publish step
killed a 3-minute LLM-heavy run. Cron just waited an hour for the
next tick; forecasts + marketImplications were stale for ~3h.

Fix: wrap atomicPublish's body (staging SET → canonical SET → staging
DEL) in withRetry. 3 attempts total, exponential backoff (1s, 2s, 4s)
≈ 7s worst-case before giving up. Each attempt re-stages with a fresh
runId so a previous attempt's staging key (if it landed server-side
but the response was lost) doesn't shadow the retry. The 5-min
staging TTL cleans up orphans naturally.

Also tag redisCommand errors so retry semantics are correct:
  - PERMANENT_4XX_STATUSES (400, 401, 403, 404, 410, 422, 451) get
    `nonRetryable: true` → withRetry exits the loop in ~10ms
  - 429 gets `retryAfterMs` from the Retry-After header → withRetry
    honors the upstream hint
  - 5xx and timeouts fall through with no flag → default exponential
    backoff applies

This is the second half of today's resilience work. PR #3649 caps
the digest replay-log lists at 100K entries to prevent the 500MB
Upstash overage that was likely back-pressuring these timeouts in
the first place. Together: #3649 removes the source of pressure;
this PR removes the single point of failure even when other
transients hit.

Tests: 5 new + 12 existing seed-utils-with-retry tests pass.
  - happy path (no retry, 3 fetch calls)
  - transient 503 → retry → success (4 calls, ≥1s elapsed)
  - permanent 401 → fast-fail, no retry (1 call, <500ms)
  - persistent 503 → exhausts 3 attempts → throws
  - 429 with Retry-After: 2 → wait ≥2s before retry

No behavior change for already-succeeding seeders. Resilience is
purely additive — the second/third attempt only fires when the
first attempt threw.
@vercel

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview May 10, 2026 4:09pm

Request Review

@greptile-apps

greptile-apps Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hardens atomicPublish against transient Upstash REST failures by wrapping the staging-SET → canonical-SET → staging-DEL unit in withRetry, and enriches redisCommand errors with nonRetryable/retryAfterMs/httpStatus tags so the retry helper makes the right backoff decision without changing single-attempt callers like acquireLock.

  • redisCommand error tagging: permanent 4xx statuses (400, 401, 403, 404, 410, 422, 451) get nonRetryable: true for immediate abort; 429 gets retryAfterMs from the upstream Retry-After header; 5xx and timeouts carry no tag and fall through to default exponential backoff.
  • atomicPublish retry loop: withRetry(fn, 2, 1000) gives 3 total attempts with 1 s / 2 s backoff windows; each attempt generates a fresh runId so a server-side-landed-but-response-lost staging key from a prior attempt does not shadow the retry.
  • Test coverage: 5 new integration tests drive the mock-fetch path for happy path, transient 503, persistent 503 exhaustion, permanent 401 fast-fail, and 429 Retry-After delay.

Confidence Score: 4/5

Safe to merge; the retry wrapping is correctly scoped to atomicPublish and does not affect NX-semantic callers like acquireLock.

The implementation is well-reasoned and the new error-tagging plumbing integrates cleanly with the existing withRetry contract. The only finding is a misleading inline comment that claims '1s, 2s, 4s ≈7s worst case' for a loop that can only produce two backoff intervals (1s + 2s = 3s) at the configured maxRetries=2.

scripts/_seed-utils.mjs — specifically the inline comment at the withRetry call site in atomicPublish.

Important Files Changed

Filename Overview
scripts/_seed-utils.mjs Adds error tagging (nonRetryable, retryAfterMs, httpStatus) to redisCommand and wraps atomicPublish's 3-call publish unit in withRetry(fn, 2, 1000); logic is correct, with one misleading comment about worst-case backoff duration.
tests/seed-utils-with-retry.test.mjs Adds 5 new integration tests covering happy path, transient 503 retry, permanent 401 fast-fail, persistent 503 exhaustion, and 429 Retry-After honor; all scenarios align with the implementation contract.

Sequence Diagram

sequenceDiagram
    participant S as atomicPublish
    participant W as withRetry
    participant R as redisCommand / redisSet

    S->>W: "withRetry(fn, maxRetries=2, 1000ms)"
    loop attempt 0..2
        W->>R: "redisSet(stagingKey+freshRunId, payload, TTL=300s)"
        alt Transient error (5xx / timeout / network)
            R-->>W: throws (no tag)
            W->>W: wait 1s x 2^attempt
        else Permanent 4xx (401, 403, 400...)
            R-->>W: "throws nonRetryable=true"
            W-->>S: rethrow immediately (no backoff)
        else 429 Rate-limited
            R-->>W: throws retryAfterMs
            W->>W: wait max(baseWait, retryAfterMs)
        else OK
            W->>R: redisCommand SET canonicalKey [EX ttl]
            W->>R: redisDel stagingKey (catch swallowed)
            R-->>W: OK
            W-->>S: payloadBytes, recordCount
        end
    end
    W-->>S: throw last error (attempts exhausted)
Loading

Reviews (1): Last reviewed commit: "feat(seed-utils): retry transient Upstas..." | Re-trigger Greptile

Comment thread scripts/_seed-utils.mjs Outdated
Comment on lines +295 to +296
2, // 2 retries (3 attempts total) — sufficient for transient blips
1000, // 1s base delay; exponential → 1s, 2s, 4s = ~7s worst case before re-attempt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The inline comment documents three backoff intervals ("1s, 2s, 4s = ~7s"), but withRetry is called with maxRetries=2, which produces only two wait periods — before attempt 1 and before attempt 2. The third interval (4s) would only occur with maxRetries=3 (4 total attempts). With the current values the actual worst-case cumulative backoff is 1 s + 2 s = ~3 s, not ~7 s.

Suggested change
2, // 2 retries (3 attempts total) — sufficient for transient blips
1000, // 1s base delay; exponential → 1s, 2s, 4s = ~7s worst case before re-attempt
2, // 2 retries (3 attempts total) — sufficient for transient blips
1000, // 1s base delay; exponential → 1s, 2s = ~3s worst case before re-attempt

Greptile flagged: comment said "1s, 2s, 4s ≈ 7s worst case" but
withRetry(fn, maxRetries=2, 1000) only fires TWO backoff intervals
(before attempts 2 and 3), not three. Actual worst-case cumulative
backoff is 1s + 2s = ~3s, not ~7s.

Real worst-case before propagation includes per-attempt fetch time
(15s timeout each × 3 attempts = 45s) + ~3s cumulative backoff ≈ 48s.

No code change — comment-only fix.
@koala73

koala73 commented May 10, 2026

Copy link
Copy Markdown
Owner Author

P2 addressed in 5e29b4b46. Greptile correctly caught the backoff-math error: with maxRetries=2 we get 2 intervals (1s + 2s = ~3s cumulative), not 3. The "~7s" was wrong by one tick.

Updated comment: "1s + 2s = ~3s worst-case cumulative wait between attempts. Plus per-attempt fetch time (15s timeout each) means total worst-case before propagating ≈ 48s."

That 48s number matters for the operator-facing budget: a single Upstash transient that lasts <48s is now absorbed silently; only persistent failures surface. Tests still 17/17 — the corrected math doesn't change any assertion (the existing tests already validated 1s + 2s timing, just the comment claimed otherwise).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant