feat(seed-utils): retry transient Upstash failures in atomicPublish#3650
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR hardens
Confidence Score: 4/5Safe 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
Sequence DiagramsequenceDiagram
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)
Reviews (1): Last reviewed commit: "feat(seed-utils): retry transient Upstas..." | Re-trigger Greptile |
| 2, // 2 retries (3 attempts total) — sufficient for transient blips | ||
| 1000, // 1s base delay; exponential → 1s, 2s, 4s = ~7s worst case before re-attempt |
There was a problem hiding this comment.
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.
| 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.
|
P2 addressed in 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). |
Summary
Wraps
atomicPublish's 3-call publish unit (staging SET → canonical SET → staging DEL) inwithRetryso a single transient Upstash REST timeout no longer kills a multi-minute seeder run. Also tags redisCommand errors withnonRetryable: truefor permanent 4xx andretryAfterMsfor 429 — so the existingwithRetryhelper 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:
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; }redisCommanditself stays single-attempt — auto-retrying every redisCommand caller (e.g.acquireLockwith NX semantics) would change behavior subtly. Only callers that opt in viawithRetryget the retry behavior.2.
atomicPublish— wrap the body inwithRetryEach 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
retryAfterMsfrom headermax(baseWait, retryAfterMs), then retriesnonRetryable: truenonRetryable: true408 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 passwithRetry,parseRetryAfterMs,PERMANENT_4XX_STATUSES,isTransientRedisErrorall unchangednode --test tests/seed-utils*.test.mjs— 54/54 pass (full seed-utils surface)What this DOES NOT change
acquireLock's single-attempt behavior unchanged — wrap at call site (acquireLockSafely:201) handles retry semantics specifically for NXredisGet) unchanged — their callers can opt in via withRetry as needed