Uh oh!
There was an error while loading. Please reload this page.
central: honor Retry-After and resume the same chunk on 429/503 (#118) - #120
Conversation
The forward sink treated any non-2xx as fatal: `postNdjson` special-cased only 401 and threw on everything else, so the first 429 unwound the whole partition. Combined with re-streaming from row 0 each tick, a partition larger than the server's byte-rate burst (~64 MB) could never fully land — the tail never delivered, and could age out of cache retention. Make 429/503 backpressure, not failure: parse `Retry-After` (falling back to the linear ladder), sleep, and retry the SAME chunk (same body + batch id, so it stays idempotent). Delivery now paces itself to the server and is correct at any volume. The inline wait per chunk is bounded (~5 min); past it the chunk throws and the driver respools — cheap now that the server dedupes the delivered prefix without charging rate budget (hyparam/hypaware-server#2). The wait is abortable, so sink close() / daemon shutdown is never wedged by a paused chunk. - backoff.js: extract the shared parseRetryAfter + ladder (from config_client.js) and an interruptible abortableSleep; config_client now imports them. - proto.md: the "Client status" caveat updated — Retry-After is honored; poison-drop and a forward cursor / proactive pacing remain follow-ups. - tests: Retry-After honored vs ladder fallback, 503-as-backpressure, ladder walk, inline-budget exhaustion -> partition retry, backpressure telemetry, and close()-aborts-a-paused-chunk; plus abortableSleep unit tests. Full suite green; typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
philcunliffe
commented
Jun 16, 2026
Dual-agent review — |
| Source | Finding (severity, evidence) | Intersects |
|---|---|---|
| Codex #1 / Claude #1 | major — sink.js:391-413 (+backoff.js:24-31) zero-delay livelock | Risks #1; Concurrency surface (postNdjson loop); Direct callers (parseRetryAfter) |
| Codex #7 / Claude #2 | major (Codex) / minor (Claude) — sink.js:391-413 undrained body | Risks #2; Concurrency surface (postNdjson loop) |
| Claude #3 | minor — sink.js/backoff.js no LLP capture / no @ref | Targets (backoff.js, sink.js); no correctness/leak surface |
Codex review
Fix Validations
429/503 backpressure should retry the same chunk
- Status: incomplete
- Evidence:
hypaware-core/plugins-workspace/central/src/sink.js:391,hypaware-core/plugins-workspace/central/src/sink.js:410,test/plugins/central-forward-chunking.test.js:297 - Assessment: Positive-delay 429/503 handling now retries the same body and batch id, with tests for
Retry-After, fallback ladder, 503, budget exhaustion, telemetry, and close abort. One legal header case still breaks the promised bounded wait; see finding.
Existing respool handling alone did not resolve #118
- Status: correct
- Evidence:
hypaware-core/plugins-workspace/central/src/sink.js:97,hypaware-core/plugins-workspace/central/src/sink.js:123 - Assessment: The existing partition retry path only reports
retryPartitionsafter failure, so without inline same-chunk pacing it can keep replaying from the partition start and hit the same server throttle.
Findings
1) Behavioral Correctness
- Severity: major
- Confidence: high
- Evidence:
hypaware-core/plugins-workspace/central/src/backoff.js:27,hypaware-core/plugins-workspace/central/src/backoff.js:29,hypaware-core/plugins-workspace/central/src/sink.js:397,hypaware-core/plugins-workspace/central/src/sink.js:410 - Why it matters: A legal
Retry-After: 0or past HTTP-date producesdelayMs === 0, sowaitedMsnever advances and thefor (;;)loop can retry the same throttled chunk forever, violating the stated 5 minute inline bound. - Suggested fix: Add a wall-clock deadline or max-attempt cap independent of
delayMs, or clamp sink backpressure sleeps to a positive minimum before applying the budget.
7) Resource Lifecycle & Cleanup
- Severity: major
- Confidence: medium
- Evidence:
hypaware-core/plugins-workspace/central/src/sink.js:391,hypaware-core/plugins-workspace/central/src/sink.js:410,hypaware-core/plugins-workspace/central/src/sink.js:430 - Why it matters: The 429/503 retry path continues without draining or cancelling the response body, which can hold sockets/body streams under sustained backpressure.
- Suggested fix: Before sleeping/retrying, consume or cancel the backpressure response body, e.g.
await response.body?.cancel().catch(() => {})or a bounded drain helper.
No Finding
- Contract & Interface Fidelity
- Change Impact / Blast Radius
- Concurrency, Ordering & State Safety
- Error Handling & Resilience
- Security Surface
- Release Safety
- Test Evidence Quality
- Architectural Consistency
- Debuggability & Operability
Evidence Bundle
- Changed hot paths:
sink.js:56createForwardSink,sink.js:185forwardPartition,sink.js:357postNdjson,backoff.js:24parseRetryAfter,backoff.js:44abortableSleep,config_client.js:255config pull throttling. - Impacted callers:
hypaware-core/plugins-workspace/central/index.js:63production sink construction;test/plugins/central-forward-chunking.test.js:121test sink construction. - Impacted tests:
test/plugins/central-forward-chunking.test.js:297,:315,:326,:338,:352,:370,:386;test/plugins/central-backoff.test.js:8,:14,:20,:29;test/plugins/central-config-pull.test.js:346,:359. - Unresolved uncertainty: I did not rerun the suite; this review is based on the diff plus targeted caller/test tracing. Server behavior from
hypaware-server#3was not inspected, so the findings assume the client must defensively handle any standards-validRetry-After.
Claude review
Claude review
Retry-After: 0 / past HTTP-date drives a tight infinite retry loop (5-min bound bypassed)
- Severity: major
- Confidence: 90
- Evidence: hypaware-core/plugins-workspace/central/src/sink.js:391-413 (with backoff.js:24-31)
- Why it matters: A legal
Retry-After: 0(RFC 9110 delta-seconds of 0) or an HTTP-date that is now/past (trivially produced by server clock skew) makesparseRetryAfterreturn0, notundefined— sodelaySeconds = retryAfter ?? ladderstays0,delayMs = 0, the budget guardwaitedMs + 0 > MAXis never true (waitedMs never advances), andbackpressureRetriesis incremented but never bounds the loop; thefor (;;)re-POSTs the same chunk as fast as the event loop allows, hammering the server and completely bypassing the advertised ~5-min inline cap. This re-introduces the tight-livelock class of bug central forward sink livelocks on server429 byte_rate— no Retry-After backoff, no resume, no forward cursor #118 was meant to kill, triggered by any standards-valid server response the PR's own defensive parse is supposed to tolerate. - Suggested fix: Bound the loop independently of
delayMs— e.g. clamp backpressure sleeps to a positive floor before the budget check, and/or add a wall-clock deadline or a max-attempt cap onbackpressureRetriesso a zero/near-zero delay still terminates inline.
Backpressure retry path leaks the un-drained response body
- Severity: minor
- Confidence: 80
- Evidence: hypaware-core/plugins-workspace/central/src/sink.js:391-413
- Why it matters: On the sleep-and-continue branch the 429/503
responsebody is never read or cancelled before the nextsend()(the throw branches do consume it viareadErrorDetail); under undici each undrained body pins its socket/stream, so sustained backpressure accumulates them — and it compounds the tight-loop finding above into a high-rate leak. - Suggested fix: Cancel or drain the body before sleeping, e.g.
await response.body?.cancel().catch(() => {})(or a small bounded drain helper) ahead ofawait sleepFn(...).
Backpressure design decision not captured in an LLP; new code carries no client-side @ref
- Severity: minor
- Confidence: 80
- Evidence: hypaware-core/plugins-workspace/central/src/sink.js:391-413, src/backoff.js:13-59 (no
llp/file touched; onlyserver LLP 0001cited) - Why it matters: Reclassifying 429/503 from fatal to bounded-inline-backpressure-with-abortable-wait is exactly the kind of non-obvious Sinks design decision CLAUDE.md's "Living docs" / "@ref" conventions target; the established pattern in this subsystem annotates such choices (e.g.
config_client.jscarries@ref LLP 0025#config-pull-loop). The wire referenceproto.mdwas updated in-commit (so no existing LLP is left factually stale — LLP 0025's 429 note is the config-pull path, untouched here), but the client-side backpressure strategy itself is recorded nowhere inllp/. - Suggested fix: Add a short note to LLP 0014 (Sinks) or LLP 0025 capturing the bounded-inline-wait + respool + abort-on-close strategy, and attach a
// @ref LLP NNNN#anchor [implements]above the backpressure loop /MAX_BACKPRESSURE_WAIT_MS.
Reports: .git/dual-review/pr-120
Dual-review (PR #120) found the 429/503 backpressure loop could livelock: a legal `Retry-After: 0` or a past HTTP-date parses to 0, and `retryAfter ?? ladder` kept that 0, so the loop retried with zero delay, never advanced the inline budget, and spun — re-introducing the class of livelock #118 set out to fix, triggered by any standards-valid server response. - sink.js: honor only a *positive* Retry-After (`||`, not `??`); a non-positive / absent / garbage value falls back to the ladder so every wait advances and the ~5-min budget bounds the retries. Drain the throttle response body before parking so undici doesn't pin the socket across the pause (and across piled-up retries). - config_client.js: the config pull loop had the same latent zero-delay reschedule; route a non-positive Retry-After to the ladder there too. - backoff.js: document the shared parseRetryAfter contract (a literal 0 / past date parses to 0; callers must treat non-positive as "no pacing"). - proto.md: amend the 429/503 wire note for the non-positive fallback. - LLP 0014: capture the forward-sink backpressure strategy (retry-in-place, positive-Retry-After-or-ladder, bounded inline wait + respool, abortable close); sink.js now carries `@ref LLP 0014#forward-sink-backpressure`. - tests: Retry-After:0 walks the ladder (no zero-delay spin) and respools; backpressure drains the response body; config pull with Retry-After:0 reschedules via the ladder, not an immediate re-poll. Full suite green (1206 pass / 1 pre-existing skip); typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
philcunliffe
commented
Jun 16, 2026
Review findings addressed — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#118. Client (proactive) half of the dual fix for the central-forward livelock; the server (defensive) half is hyparam/hypaware-server#3 (Fixes hyparam/hypaware-server#2).
Problem
The forward sink treated any non-2xx as fatal —
postNdjsonspecial-cased only401and threw on everything else, and that throw unwound the whole partition. With the sink also re-streaming each partition from row 0 every tick, a partition larger than the server's byte-rate burst (~64 MB) could never fully land: the server429s once the bucket drains, the client gives up, and the tail beyond the burst is never delivered (and can age out of cache retention → silent loss). The server even sentRetry-After: 30, which the client ignored.Fix
Treat
429/503as backpressure, not failure:Retry-After(fall back to the linear ladder30→60→120→300when absent/garbage),X-Hyp-Batch-Id, so it stays idempotent,close()aborts an in-flight pause so daemon shutdown is never wedged.401keeps its one-shot refresh+retry. Other4xx/5xxstill throw (driver classifies). Narrowing poison-drop (400/422) and adding a forward cursor / proactive pacing remain follow-ups — called out inproto.md.Structure
central/src/backoff.js: the canonical home forparseRetryAfter+RETRY_BACKOFF_SECONDS(lifted fromconfig_client.js, which now imports them) and a new interruptibleabortableSleep.proto.md: the "Client status" caveat rewritten —Retry-Afteris now honored.Tests
central-forward-chunking.test.jsgains: Retry-After honored vs ladder fallback,503-as-backpressure, repeated-429 ladder walk, inline-budget exhaustion → partition retry,central.forward.backpressuretelemetry, andclose()aborting a paused chunk (realabortableSleep). Newcentral-backoff.test.jsunit-testsabortableSleep. Backpressure waits are driven through an injectedsleepFnso tests don't actually sleep.Full suite green (1203 pass / 0 fail / 1 pre-existing skip);
npm run typecheckandnpm run lintclean.Rollout
Land hyparam/hypaware-server#3 first (backward-compatible; helps unpatched clients), then this.