Skip to content

fix: [sc-106110] Cap and jitter the auto-update retry backoff - #101

Merged
mlataza merged 4 commits into
mainfrom
bug/sc-106110/cap-and-jitter-the-auto-update-retry-backoff-to
Aug 13, 2026
Merged

mlataza merged 4 commits into
mainfrom
bug/sc-106110/cap-and-jitter-the-auto-update-retry-backoff-to

Conversation

@mlataza

@mlataza mlataza commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

The auto-update retry delay was r.baseBackoff * (1 << attempt) — no ceiling, no jitter.

Unjittered, every agent that fails against the release endpoint at the same moment retries at exactly base, 2*base, 4*base, so a GitHub API outage or rate-limit response is sustained by the fleet trying to recover from it, and endpoints stay on old versions — missing fixes we have already shipped — far longer than the outage lasted.

Uncapped, the production 5 minute base reaches a 42 hour sleep by attempt 10, longer than the 48 hour check interval the retries are nested inside, and because maxRetries is injectable a large value overflows the shift into a negative time.Duration: time.After fires immediately and the loop issues HTTP requests as fast as it can.

This is the defect class already fixed for postback retries in sc-103965; the update path never got the same treatment.

Changes

Cap and jitter (f580066). Extracted the postback fix's schedule math into utils.JitteredBackoff rather than duplicating it, and pointed both retry paths at it. The doubling is iterated multiplication with an early exit at the cap, so no intermediate value can overflow; jitter of up to ±25% spreads each slot. The update cap is the lower of a documented DefaultUpdateMaxRetryBackoff (1 hour) and a quarter of the check interval, so the retry budget always stays nested inside the interval it belongs to. The production schedule is unchanged where it matters — 5m, 10m, 20m, 40m — and then flattens at an hour. postbackRetryBackoff keeps its own defaults and is otherwise unchanged.

Integration coverage (9cd12bc). A scenario on all three platforms drives the retry loop against test/stubrelease, a stub release endpoint that fails and recovers on demand. Reaching it needs a build-time-gated seam: releaseUrlOverrideFileStr is injected only into the integration binary; released builds leave it empty and never look for the override file, so a shipped agent's update source stays fixed at build time.

The scenario found two further defects, both fixed here:

Silent stop (15a1db0). The stop was prompt — honored 276ms into a 2.17s backoff on Linux — but nothing was logged. Start() logged Auto updater stopped only when its own select saw the stop; a stop observed inside the backoff wait returned through retryWithBackoff and exited silently, so the one path where a stop must interrupt a pending sleep was the only one leaving no record.

Jitter collapsing at the cap (b62503d). Jitter was applied and then clamped to maxBackoff, so roughly half of every capped slot landed on exactly the ceiling. That is this ticket's synchronization surviving where it matters most: a sustained outage walks the whole fleet up to the ceiling and holds it there, and from then on about half of it retries at the same instant. A jittered value above the ceiling is now reflected back under it (2*max - value), which lands in [0.75*max, max] and stays strictly positive. The postback schedule shares the helper and benefits too.

Verification

Unit tests assert every slot is strictly positive and capped across the full attempt range for absurd maxRetries (64/128/1000, plus spot checks past 1<<20), that repeated draws of one slot produce a distribution rather than a repeated value, that capped draws do not pile onto the ceiling (fails above 1% of 1000 samples; the clamped implementation scored ~500), that a short-interval build's whole budget fits inside its interval, and that both stop paths log exactly once.

Integration test run: https://github.com/RewstApp/agent-smith-go/actions/runs/31653559872 — all 13 jobs green. Observed schedules against a 503 endpoint, cap 7.5s (a quarter of the integration build's 30s interval):

Platform attempt 1 2 3 4 recovery stop mid-backoff
ubuntu-latest 2.134s 3.480s 6.380s 7.179s succeeded on retry 0.1s
windows-latest 1.842s 4.493s 7.408s 7.012s succeeded on retry 0.6s
macos-latest 2.441s 3.816s 7.148s 6.110s succeeded on retry 0.1s

Every slot bounded by the cap, four distinct values per run, never served back to back, and the schedule recovered on a retry when the endpoint started serving a valid payload mid-sequence.

Follow-up

ReconnectTimeoutGenerator.Next has its own copy of the clamp and the same pile-up at its ceiling. Left alone to keep this change scoped to the update and postback paths; worth its own ticket.

The auto-update retry delay was computed as baseBackoff * (1 << attempt) with no
ceiling and no jitter - the same defect already fixed for postback retries in
sc-103965, which the update path never got. The unjittered schedule is the more
immediate problem: every agent that fails against the release endpoint at the
same moment retries at exactly base, 2*base, 4*base, so a GitHub API outage or
rate limit turns the fleet into a synchronized retry storm that sustains the
condition it is recovering from, and endpoints stay on old versions - missing
shipped fixes - far longer than the outage lasted. The missing ceiling is the
quieter half: at the production 5 minute base, attempt 10 alone would sleep over
42 hours, longer than the 48 hour check interval the retries are nested inside,
and because maxRetries is injectable a large value overflows the shift into a
negative time.Duration, so time.After fires immediately and the loop spins
issuing HTTP requests as fast as it can.

Extract the postback fix's schedule math into utils.JitteredBackoff rather than
duplicating it, and point both retry paths at it. The doubling is performed by
iterated multiplication with an early exit at the cap, so no intermediate value
can overflow; jitter of up to +/-25% is applied after clamping and the result is
re-clamped, so every slot is strictly positive, never exceeds the cap, and
spreads across a fleet. postbackRetryBackoff keeps its own defaults and is
otherwise unchanged in behavior.

The update cap is the lower of a documented DefaultUpdateMaxRetryBackoff (1
hour) and a quarter of the check interval, so the retry budget always stays
nested inside the interval it belongs to. The interval fraction matters for
builds that shorten the interval via ldflags; at the production 48 hour interval
the absolute ceiling is far lower and wins. The production schedule is unchanged
where it matters - 5m, 10m, 20m, 40m - and then flattens at an hour instead of
growing to 80 minutes, 42 hours, and eventually a negative sleep. The backoff
wait still selects on the stop signal, so a service stop is never delayed by a
pending retry.

Tests assert every slot is positive and capped across the full attempt range for
deliberately absurd maxRetries values (64, 128, 1000) and spot checks past
1<<20, that repeated computations of the same slot produce a distribution rather
than one repeated value, that a short-interval build's whole retry budget fits
inside its interval, and cover cap selection, base-above-cap clamping and
non-positive inputs. The existing stop-during-backoff test now raises the cap
explicitly so it still measures a stop interrupting a genuinely long wait.
… integration workflow

The cap and the jitter are unit tested against the schedule function, which
cannot show that a real agent facing an unavailable release endpoint now retries
on bounded, spread-out slots instead of a synchronized schedule that can overflow
into a busy-spin. Add a scenario that drives the retry loop on all three
platforms against an endpoint that fails and recovers on demand.

A GitHub outage is not something CI can arrange, and an unroutable address
produces connection errors rather than the HTTP failure the ticket describes, so
the scenario needs a stub the agent will actually query. The release endpoint is
compiled in, so it gets a build-time-gated seam: releaseUrlOverrideFileStr names
a file in the org's data directory whose contents replace the endpoint, and it is
injected only into the integration binary. A released build leaves it empty and
never looks for the file at all, so a shipped agent's update source stays fixed
at build time and cannot be redirected by dropping a file on an endpoint. Unit
tests pin both halves: the released build ignoring a planted override, and the
integration build honoring a valid one while falling back to the compiled-in
endpoint for a missing, empty or whitespace-only file - a fixture that failed to
write the file leaves the agent updating normally rather than silently not
updating at all.

The fixture, test/stubrelease, answers over plain HTTP on loopback, so unlike the
stub broker nothing has to be installed into the host trust store. It fails every
request with 503 until its mode file flips, which it re-reads per request, so the
endpoint can recover in the middle of a retry sequence. It logs every arrival with
millisecond precision, which is the request-spacing record the ticket asks for,
and serves the running agent's own version as tag_name so a recovered check ends
at "No updates available" - the recovery is what is under test, not the installer.

The scenario points the agent at the failing stub, installs the integration binary
with auto-updates on, waits for three retries, flips the endpoint mid-sequence and
asserts the update succeeds on a retry, then asserts the schedule itself: it
prints an attempt/backoff/elapsed table and checks every slot is strictly
positive, within the cap, jittered, growing, and never served back to back.
Distinct backoff values alone would be weak evidence - the doubling produces those
on its own until it reaches the ceiling - so the slots that do reach the ceiling
must differ from each other, which is what an unjittered schedule cannot do. It
then fails the endpoint again and asserts a service stop issued mid-backoff is
honored in under 20s rather than waiting out the remaining budget. Every restore
step runs under always(), so a failed assertion cannot leave the rest of the job
running an agent pointed at a stub endpoint or leave the service stopped.

The integration build's base backoff drops from 10s to 2s. With the 30s check
interval the cap is a quarter of it (7.5s), so a 10s base was clamped on every
slot and the doubling was unobservable; at 2s the schedule climbs 2s, 4s, then
flattens at the cap, and both halves of the fix show up in one sequence.

Verified locally as far as a macOS host allows: the fixture serves 503 and the
release payload correctly, driving the real AutoUpdateRunner against it produced
1.87s, 3.48s, 7.5s, 6.29s, 7.49s, 6.41s - capped and jittered - and a mid-sequence
flip logged "Update succeeded on retry: attempt=3". The analysis script passes on
that real log and fails correctly on synthetic pre-fix logs: uncapped doubling,
negative durations arriving back to back, and a capped but unjittered schedule.
Every new PowerShell block parses under pwsh and actionlint is clean. The actions
themselves - service managers, the WMI launch, elevation - can only run on the
runners.
… a backoff

The integration scenario proved the retry wait is interruptible - the stop was
honored 276ms into a 2.17s backoff on Linux, 474ms and 541ms into comparable
slots on Windows and macOS - but its assertion failed on all three platforms
because nothing was written to the log.

The run loop logs "Auto updater stopped" only when its own select observes the
stop, i.e. while idle between checks. A stop observed inside the backoff wait
returns through retryWithBackoff instead and exited silently, so the one path
where a stop actually has to interrupt a pending sleep - the path the bounded,
jittered schedule exists for, and the one an operator waiting on a service stop
is watching - was the only one that left no record. Log it there too, exactly
once, so both exits are observable.

Unit tests cover both exits: a stop delivered while the runner is inside a
backoff wait (verified to be inside it before the stop is issued, so it cannot be
the idle select that answers) and a stop delivered while idle, each asserting the
line appears exactly once.

The workflow now waits for four retries rather than three before flipping the
endpoint back to healthy. With three, only one slot had reached the ceiling, so
the assertion that distinguishes a jittered schedule from a merely capped one -
the capped slots must differ from each other - had a single sample and was
skipped. Four puts two slots at the ceiling and still leaves ~15s of budget for
the flip to land in.
…amping to it

The integration scenario failed on Linux and Windows with both of its capped
slots measuring exactly 7.5s, and passed on macOS - a 25% coin flip, not a flaky
assertion. The jitter was applied and then clamped to maxBackoff, so every draw
that came out above the ceiling landed on the ceiling exactly. At the cap the
jitter is symmetric, which means roughly half of all capped slots collapsed onto
a single value.

That is the synchronization this ticket set out to remove, surviving in the one
place it matters most. A brief failure is spread by the early slots, but a
sustained outage walks every agent up to the ceiling and holds it there, and from
then on about half the fleet retried at the same instant on every slot - the herd
re-forms exactly when the endpoint is least able to absorb it.

Reflect instead: a jittered value above the ceiling becomes 2*max - value, which
lands in [0.75*max, max] and is still strictly positive, since the pre-jitter
value is at most max and the jitter at most a quarter of it. Capped slots now
spread across the band below the ceiling rather than piling onto it, and the
documented guarantee - never above the cap, never zero or negative - is unchanged.

The regression test samples a capped step 1000 times and fails if more than 1%
land on exactly the cap; the clamped implementation scored around 500. This also
benefits the postback retry schedule, which shares the helper. The reconnect
backoff generator has its own copy of the clamp and the same behavior at its
ceiling; it is left alone here to keep this change scoped to the update and
postback paths, and is worth a follow-up.
@mlataza
mlataza merged commit d5ead54 into main Aug 13, 2026
25 checks passed
@mlataza
mlataza deleted the bug/sc-106110/cap-and-jitter-the-auto-update-retry-backoff-to branch August 13, 2026 00:39
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