Skip to content

fix(agent): retry a transient heartbeat failure before counting it - #3961

Merged
kwakayama merged 5 commits into
mainfrom
fix/issue-709-heartbeat-transient-retry
Aug 22, 2026
Merged

fix(agent): retry a transient heartbeat failure before counting it#3961
kwakayama merged 5 commits into
mainfrom
fix/issue-709-heartbeat-transient-retry

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Description

The agent's push-runtime-service heartbeat gave up on the first non-2xx response. A
transient 5xx from the control plane therefore counted straight toward
consecutiveHeartbeatFailures, and three of those escalate to the
Agent service heartbeat failing persistently error — so a failure lasting about a
second spent a third of that budget with no attempt to recover.

heartbeatAgentPushRuntimeService now retries within one tick:

  • Up to 3 attempts, via the existing retryWithBackoff helper from #veryfront/errors.
  • Retried: a 5xx from the control plane, and a request that never reached it (the
    fetch rejection is reported as a 503 while keeping the original transport message,
    so the log line is unchanged).
  • Not retried: any 4xx. An unknown service id or a rejected token is a real error
    that retrying only delays.
  • Bounded backoff: the backoff doubles and is scaled to a quarter of the configured
    heartbeat interval (heartbeatRetrySchedule). This bounds the waits between attempts
    and nothing else — a request has no deadline, so backoff alone cannot keep a tick
    inside its interval.
  • No overlapping ticks: the interval callback skips a beat while one is still in
    flight, and logs the skip. That holds however slow a single attempt is. A per-attempt
    timeoutMs would not — three bounded attempts can still overrun a short interval —
    and it would newly abort slow requests that do succeed. The trade is that a
    persistently slow control plane escalates more slowly, since a skipped beat is not a
    failure; the skip log keeps a wedged heartbeat visible.
  • Cancellable: the lifecycle owns an AbortController that stop() aborts. The
    signal is threaded through retryWithBackoff to both the pending backoff timer and
    the in-flight request, so teardown leaves nothing pending that could hold a process
    open. An abort during teardown is swallowed rather than counted as a failure.

Failures are classified by error slug plus an httpStatus recorded in the error
context, rather than by overwriting the registry error's own status with the upstream
one — so NETWORK_ERROR keeps its 502 and registration failures keep their prior status.

consecutiveHeartbeatFailures is untouched: it still increments only when the whole
attempt sequence fails, so persistent failures escalate exactly as before.

Related Issue(s)

Refs veryfront/veryfront-issue-inbox#709

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Checklist

  • I have made corresponding changes to the documentation (if applicable)
  • I have added tests that prove my fix is effective or that my feature works

Tests

Red-then-green is not uniform across these, so stated precisely: two were genuinely red
before their fix (retries a transient 500 … failed with VeryfrontError: … HTTP 500, and
the two below marked as such). The rest are regression guards that were green on both sides;
mutation testing, not a red run, is what establishes those have teeth.

  • retries a transient 500 so a one-second blip never counts as a failure — 500 then 200
    resolves in two attempts and logs no failure.
  • fails a client error immediately without retrying — 400/401/404 each reject after
    exactly one attempt. This is the regression guard.
  • keeps one tick's backoff waits inside the heartbeat interval — schedule arithmetic only,
    and now named for what it actually asserts. Its previous name promised the no-overlap
    property while testing none of it, against an instantly-resolving double.
  • never runs two heartbeat ticks at once, even when attempts outlast the interval — a fetch
    double with real latency, asserting max concurrent in-flight is 1. Genuinely red at 3
    concurrent
    before the guard; neutering the guard to if (false) reproduces that.
  • still escalates persistent 500s, in bounded time — constant 500s still reach
    consecutiveFailures=3, within a bounded wall-clock budget, which proves the retry
    sequence terminates.
  • cancels a pending retry backoff when the lifecycle stopsstop() during a backoff
    must leave no timer pending and fire no further request. Genuinely red at 254 ms (the timer
    ran to completion and retried after teardown), green at 1 ms.

The push-runtime-service heartbeat gave up on the first non-2xx response, so a
transient 5xx from the control plane counted straight toward
consecutiveHeartbeatFailures. Three of those escalate to the persistent-failure
error, which meant a blip lasting a second spent a third of that budget.

Retry the heartbeat up to three times within one tick, on a 5xx and on a
request that never reached the control plane. A 4xx still fails on the first
attempt: an unknown service id or a rejected token is a real error that
retrying only delays.

The backoff doubles and is scaled to a quarter of the configured heartbeat
interval, so one tick's retry sequence always finishes before the next
scheduled tick, however short that interval is.

Refs veryfront/veryfront-issue-inbox#709
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c3ae6d5-390c-453c-bdee-1947a093d0ce

📥 Commits

Reviewing files that changed from the base of the PR and between 559f04b and f7b00c5.

📒 Files selected for processing (3)
  • docs/api-reference/veryfront/agent.md
  • src/agent/service/registration.test.ts
  • src/agent/service/registration.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

📦 Client bundle boundary

Entrypoint Modules Source size Server leaks
src/index.client.ts 327 1960 KiB ✅ 0

A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in scripts/lint/client-bundle-baseline.json to burn down.

stop() cleared the interval but left an in-flight retry running: the backoff
timer stayed pending and woke to fire one more request after teardown, which
can hold a process open during shutdown.

Give the lifecycle an AbortController, abort it in stop(), and thread the
signal through retryWithBackoff to both the pending backoff and the in-flight
request. An abort during teardown is swallowed rather than counted, since it
is a shutdown rather than a heartbeat failure.

Also classify the failure by error slug plus an httpStatus recorded in the
error context, instead of overwriting the registry error's own status with the
upstream one. NETWORK_ERROR keeps its 502, and registration failures keep the
status they had before this branch.

Refs veryfront/veryfront-issue-inbox#709
@kwakayama

Copy link
Copy Markdown
Contributor Author

Review — score: 85/100. fix-then-merge. Not merging yet.

Independently reviewed by running and mutating the code. This repo currently gets no automated
review at all (veryfront-issue-inbox#724), so this is the only review signal.

Finding 1 — CONFIRMED, medium. Blocking.

The bounding claim is false, and the test named after it cannot detect that.

registration.ts:258-260 and the PR body both state that "one tick's retry sequence always
finishes before the next scheduled tick, however short the interval is." It does not.

heartbeatRetrySchedule bounds only the backoff waits (≤25% of the interval). Per-attempt
request duration is unbounded: retryWithBackoff has a timeoutMs option
(src/errors/error-handlers.ts:120, wired at :184-188) and the heartbeat call at
registration.ts:472 never passes it. There is also no in-flight guard on the setInterval
callback. Measured at interval 200 ms with 100 ms attempt latency:

total heartbeat requests = 14
MAX CONCURRENT IN FLIGHT  = 2

Overlapping ticks — the exact outcome the comment promises cannot occur.

Worse, the test named keeps one tick's retry sequence inside the heartbeat interval
(registration.test.ts:337) uses an instantly-resolving fetch double. It asserts the schedule
arithmetic and nothing whatsoever about the property in its own name. A test that cannot fail for
the thing it is named after is worse than no test, because it reads as coverage.

Honest severity: at the 30 s production default you need ~10 s per attempt to overlap. Issue #709
records duration_ms=2704, so a degraded control plane can reach it — and this PR triples the
sequence length, making overlap roughly 3× easier than on main. Heartbeats are idempotent POSTs,
so the harm is doubled load on an already-struggling control plane plus out-of-order
consecutiveHeartbeatFailures increments.

Fix: pass timeoutMs, or guard the interval callback. At minimum, correct the false comment —
but shipping a comment that promises a property the code does not have is how the next person
gets misled.

Finding 2 — CONFIRMED, low

No test covers the transport-rejection path. A fetch rejection does retry 3× as intended, but
every fetch double in the suite resolves. If someone later attaches an httpStatus there,
retries silently stop and nothing goes red.

Finding 3 — minor, doc

The body says the fetch rejection "is reported as a 503". The final commit changed that: the code
sets no httpStatus (:428-434) and relies on undefined. A later paragraph in the same body
describes it correctly. Stale sentence.

Finding 4 — scope observation, not a defect

The identical unretried-heartbeat + consecutive-failure-count pattern lives at
src/sandbox/lazy-sandbox.ts:687-704, untouched. Outside #709's scope; worth a follow-up.

What is verified right — by mutation, not by reading

  • 4xx fails immediately. isRetryableHeartbeatFailure (:449) requires the NETWORK_ERROR
    slug and httpStatus undefined or 500-599. Making it return true unconditionally turns
    fails a client error immediately without retrying red. A 200 with a malformed body is also not
    retried — the schema error is not a NETWORK_ERROR.
  • Teardown cancellation is real. stop() calls teardown.abort() (:559); the signal threads
    through retryWithBackoff into both the pending backoff and the in-flight fetch. Removing
    teardown.abort() turns cancels a pending retry backoff when the lifecycle stops red. No
    dangling timer.
  • Reused retryWithBackoff from #veryfront/errors — no second retry engine.
    heartbeatRetrySchedule is schedule arithmetic, not a parallel mechanism.
  • vf-error-handling compliant; no sanitizer suppression in any new test, so vf-testing
    compliant. +373 breaks down as 221 test / 139 source / 13 regenerated doc line numbers.
    templates/manifest.generated.ts is not in the diff. 31/31 CI green.

One correction to the PR body's red-green story

It is thinner than written. Reverting registration.ts alone breaks module load, so the reviewer
stubbed the new export onto the old source for a fair run. Result: 1 of 5 tests is truly red
(retries a transient 500). Two pass vacuously against main, one dangles an unhandled rejection
that kills the module, and one never ran. The suite is still sound — mutation testing on the
current source is what establishes the remaining guards have teeth, and two demonstrably do — but
"each watched fail" is not accurate.

To reach the bar

Finding 1. Findings 2-4 are follow-ups.

@kwakayama
kwakayama marked this pull request as draft August 22, 2026 09:44
…ning

The backoff schedule bounds only the waits between attempts. A request has no
deadline, so a slow control plane pushed a tick past its own interval and the
next tick started on top of it. Measured at a 200ms interval with 120ms of
request latency: 3 heartbeats concurrently in flight.

Retries made this easier to reach by tripling the length of a tick, so guard
the interval: while a tick is in flight the next beat is skipped and logged
rather than started. That holds however slow a single attempt is, which a
per-attempt timeout would not — three bounded attempts can still overrun a
short interval. It also avoids newly aborting slow requests that do succeed.

The trade is that a persistently slow control plane escalates more slowly,
since a skipped beat is not a failure. The skip is logged so a wedged
heartbeat stays visible.

Correct the budget-ratio comment, which claimed the ratio kept two heartbeats
from overlapping, and rewrite the test that was named for that property but
asserted only schedule arithmetic against an instantly-resolving double.

Refs veryfront/veryfront-issue-inbox#709
…ipped

The in-flight guard introduced a beat that neither succeeds nor fails, and
nothing asserted which. If a skipped beat reset consecutiveHeartbeatFailures,
a slow-failing control plane would never escalate at all — the counter would
be knocked back before it could reach three.

Drive a lifecycle whose ticks outlive their interval, assert beats were
actually skipped, and assert escalation still reports exactly three
consecutive failures. Resetting the counter on the skip path makes it fail.

Refs veryfront/veryfront-issue-inbox#709
@kwakayama

Copy link
Copy Markdown
Contributor Author

Re-review after 69877b35a5 — score: 85 → 92/100. Merge.

The concurrency measurement was re-run, same setup, same instrumentation

Interval 200 ms, attempt latency 100 ms:

BEFORE  requests=14   MAX CONCURRENT IN FLIGHT = 2
NOW     requests=8    MAX CONCURRENT IN FLIGHT = 1   skips logged = 2

It is 1. The guard fires, and it is visible in logs rather than silently swallowing a beat.

The vacuous test was rewritten properly — this was the under-90 condition

It was not patched over, it was split honestly:

  • the arithmetic-only test was renamed to keeps one tick's backoff waits inside the heartbeat interval, so its name now matches what it actually asserts, with a comment pointing at the real
    coverage
  • a new test, never runs two heartbeat ticks at once, even when attempts outlast the interval, uses real 120 ms latency, waits for ≥6 requests so several ticks must decide, and
    asserts maxConcurrent === 1

And it has teeth: disabling the guard (if (false && heartbeatInFlight)) turns exactly that test
red. Renaming a test to tell the truth is the part I want to call out — the previous name was the
reason this shipped.

The false comment at registration.ts:239-247 now states what the code enforces: the ratio
"bounds the waits between attempts and nothing else… overlap is prevented by the in-flight guard
on the interval below, not here."

Nothing regressed, and the skip semantics were verified independently

Both prior mutations re-run, each killing exactly one test: isRetryableHeartbeatFailure → true
reddens fails a client error immediately without retrying; removing teardown.abort() reddens
cancels a pending retry backoff when the lifecycle stops.

The two properties I specifically asked about:

  • Escalation survives skipping. 60 ms interval, 30 ms latency, constant 500s: escalated,
    consecutiveFailures=3 within 2802 ms, 21 skips logged.
  • A skipped tick is not counted as a success. 100 ms interval, 150 ms latency so ticks are
    skipped between failures: consecutiveFailures at successive escalations = [3,4,5,6,7],
    strictly monotonic. The reset lives in .then(), which runs only on a real success, while
    .finally() only clears the flag.

I had also checked separately that heartbeat is an async arrow, so it always returns a promise
and finally always clears the flag — there is no path where a stuck flag stalls heartbeats
permanently, which would have been worse than the original bug.

One residual, verified pre-existing rather than assumed

A permanently hung heartbeat never escalates: 1 request, 27 ticks skipped, 0 escalations over
1500 ms. Crucially, the same probe against the parent commit 36defb4bc7 with the guard absent
gives 28 requests and still 0 escalations — so the gap predates this change, and the guard
strictly improves the resource side (1 hung request instead of 28). A per-attempt timeoutMs
would close it and is complementary to the guard, not an alternative. Follow-up issue, not a
blocker.

Carried forward, minor: no test for the transport-rejection retry path; the PR body's stale
"reported as a 503" sentence.

Gates

deno lint 0, deno fmt --check 0, src/agent/service/ → 51 passed (115 steps). CI still
running on this commit
— merging only once green.

@kwakayama
kwakayama marked this pull request as ready for review August 22, 2026 10:07
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@kwakayama
kwakayama added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit c009906 Aug 22, 2026
34 checks passed
@kwakayama
kwakayama deleted the fix/issue-709-heartbeat-transient-retry branch August 22, 2026 10:27
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