fix(heartbeat): status-gate the errorMessage allocation-fault path (BLO-20343) - #928
Conversation
…LO-20343) Follow-up from Ally's review of #889. `isHintlessTransientUpstreamFault` wrapped the `resultJson` allocation scan in `allocationFaultStatusGate` but ran the `errorMessage` scan unconditionally, so the same gateway `allocation_missing` bytes classified differently depending only on which field carried them: `{api_error_status: 401}` + `resultJson.result` was `false`, the same payload in `errorMessage` was `true`. Not a known defect — reaching the split needs contradictory adapter state (an errorMessage opening with a 400 gateway payload while resultJson reports an authoritative non-400). No producer was found: `api_error_status` is only ever read in this repo, straight off the SDK's final result event. Move the `errorMessage` scan inside the existing gate. Resolved toward `deny` because every status that reaches the gate is one the retry curve cannot help: 401/403 will not self-heal, 500 is deliberately terminal, and 429 belongs to the rate-limit family's flat curve. Costs nothing for the two shapes that actually occur — the real fault carries `api_error_status: 400` (gate allows) and the errorMessage-only cases pass no resultJson at all (gate allows). Pins the one input shape this feature shipped without coverage for (`{api_error_status: 401}` + gateway-shaped errorMessage), plus the symmetry property itself across 400/401/403/500. Verified load-bearing: with the production change reverted and the test kept, `gates the errorMessage path on status exactly as it gates resultJson` fails `expected true to be false` while all 14 existing predicate tests pass.
1 similar comment
|
@ally please review — this is the follow-up you asked for in your #889 review (the non-blocking Review focus, in priority order:
Not in scope (happy to file separately if you want them): the duplicated |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Looks good. Moving the Suggestions (1)
Strengths
Recommended ActionLooks good to merge once required CI checks complete. |
The PR body originally omitted the required template sections (Thinking Path / What Changed / Risks / Model Used / dedup-search checkbox); the body has been corrected. commitperclip-review.yml triggers on [opened, synchronize, reopened] only, so a description edit does not re-run it — this empty commit supplies the synchronize event. No code change. Squash-merge drops this commit.
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Looks good. The allocation-fault status gate now covers both Suggestions (1)
Strengths
Recommended ActionThe patch is clean, but this PR is authored by |
Thinking Path
Linked Issues or Issue Description
bounded-retry stale-killed pr_review runs) is the only other open heartbeat-retry PR and touches a disjoint path. No duplicate.This is a consistency + coverage fix, not a defect fix. Sized accordingly.
In
isHintlessTransientUpstreamFault, theresultJsonallocation scan was wrapped inallocationFaultStatusGate(...) === "allow", but theerrorMessagescan ran unconditionally:{api_error_status: 401}+resultJson.result= gateway payloadfalsefalse{api_error_status: 401}+errorMessage= same payloadtruefalseReaching the split requires an
errorMessagethat begins with a 400 gateway payload whileresultJsonreports an authoritative non-400 — the adapter reporting 401/403/500 while surfacing a 400 gateway body. Neither Ally nor I found a path that produces it.What Changed
server/src/services/heartbeat.ts— moved theopts.errorMessageallocation-fault scan inside the existingallocationFaultStatusGateblock, with theresultJsonkey loop kept under its own null check so theresultJson === nullcase still reaches theerrorMessagescan.server/src/services/heartbeat.ts— inline comment recording why the tie is resolved towarddenyand that the triggering shape has no known producer.server/src/__tests__/heartbeat-hintless-transient-upstream.test.ts— new casegates the errorMessage path on status exactly as it gates resultJson: pins{api_error_status: 401}+ gateway-shapederrorMessage(anderror_status, and 403/500), asserts the symmetry property directly across 400/401/403/500, and pins that the gate stays open for the two shapes that actually occur.Resolved toward
denyrather than documenting the asymmetry, because every status that can reach the gate is one the retry curve cannot help: 401/403 auth will not self-heal; 500 is deliberately terminal (already pinned by"a real server bug, not a brownout"); 429 is owned by the rate-limit family's flat 90s curve. Costs nothing for the real shapes — the observed fault carriesapi_error_status: 400(gate allows) and theerrorMessage-only cases pass noresultJson(gate allows).Verification
CI job
server-tests, suiteserver/src/__tests__/heartbeat-hintless-transient-upstream.test.ts.The new test is load-bearing — production change reverted, test kept:
The 14 that still pass are the existing predicate tests, so the new case isolates exactly the asymmetry and nothing else.
With the fix restored:
One number to reconcile before it reads as drift: #889 reported
17 passedfor this suite, this reports 20. That is 17 pre-existing + 1 new + the 2 embedded-Postgres end-to-end cases, whichdescribeEmbeddedPostgresskipped on #889's host and ran on mine. Not new tests.The four BLO-19879 tests and the 503 end-to-end case pass unchanged.
Risks
Low, with one honest caveat I would rather a reviewer weigh than have me wave away.
api_error_statushas 7 occurrences in this repo and all 7 are reads; nothing writes it, it arrives verbatim on the Claude SDK's final result event. But theclaude_k8sadapter that carries it is an out-of-repo plugin, so I could not trace it end to end. Reviewer input specifically wanted here.Model Used
Claude Opus 5 (
claude-opus-5), 1M context, extended thinking enabled, with tool use and code execution via Claude Code. Tests, typecheck, and the revert-to-prove-the-test-fails check were executed locally by the model in an isolated git worktree; no result above is asserted from reading alone.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template