feat(retry): pace retries and classify errors instead of giving up (audit theme 7) - #143
feat(retry): pace retries and classify errors instead of giving up (audit theme 7)#143passcod wants to merge 4 commits into
Conversation
Every retry site needs a back-off, so failure does not mean hammering, and a recovery path, so failure does not mean death. Each audited site had at most one. RetryGate generalises the semantics scheduler::should_back_off already implements and tests — capped exponential, with a gap past the cap resetting the count — for callers whose cadence is the reconciler tick rather than a row in the operations log. It has no exhausted state by construction: past a threshold the caller escalates to a fault and keeps attempting at the cap. Image pulls retried immediately on every 5 s tick, then set an exhausted flag nothing could clear, because entries left the map only on a success that could no longer be attempted. TLS issuance dispatched Tailscale hostnames before loading the decision that holds the operator block and the failure debounce, so a downed tailscaled opened a failed attempt row every tick — and the 1000-row attempt cap then evicted other hostnames' last_attempt, dissolving their debounce too. The UDP relay treated a legal zero-length datagram and an ICMP port-unreachable as fatal, and exited without telling anyone, leaving the forward listed as healthy. ctl's forward loop killed the whole forward on an oversized datagram, where the server relay drops and reports — the two ends of one protocol disagreed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
🟡 Not ready to approve
RetryGate does not actually reset the consecutive-failure count after a gap beyond the cap, which breaks the stated semantics and can mis-drive escalation thresholds after a long quiet period.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR addresses audit theme 7 by introducing shared retry pacing for tick-driven retries and tightening UDP forward/relay error classification so transient/per-datagram failures don’t permanently disable long-lived behaviour. It also updates the specs to codify the required retry/backoff and relay resilience semantics.
Changes:
- Add
RetryGate/RetryGatesfor capped exponential backoff without a terminal “exhausted” state, and use it to pace image-pull retries. - Move Tailscale issuance dispatch behind the unified decision computation and apply the retry block + debounce gating consistently.
- Align UDP forward/relay handling to treat
TooLarge/zero-length/transient socket errors as non-fatal, surface relay termination, and add dropped-datagram stats.
File summaries
| File | Description |
|---|---|
| docs/spec/runtime.md | Adds spec item r[actuate.image.retry] describing bounded backoff + non-terminal escalation semantics for image pulls. |
| docs/spec/interface.md | Adds spec item i[forward.relay.resilience] defining per-datagram vs per-forward failure classification and termination reporting. |
| crates/protocol/src/client.rs | Changes OiClient::send_datagram to return quinn::SendDatagramError so callers can classify TooLarge vs ConnectionLost. |
| crates/ctl/src/forward.rs | Updates client-side UDP forwarding to drop/count oversize datagrams and only break on ConnectionLost. |
| crates/core/src/system/actuator/pull.rs | Replaces terminal pull exhaustion with a retry gate to pace pulls and allow recovery after extended failure. |
| crates/core/src/runtime/tls/issuance.rs | Ensures tailscale issuance respects the central decision (retry block + debounce) before dispatch. |
| crates/core/src/runtime/retry.rs | Introduces per-key retry pacing primitives and unit tests. |
| crates/core/src/runtime.rs | Exposes the new runtime::retry module. |
| crates/core/src/oi/forwards/session.rs | Makes UDP relay resilient to zero-length datagrams and transient recv errors; reports task termination to opener. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| pub fn record_failure(&mut self, now: Instant) { | ||
| self.failures = self.failures.saturating_add(1); | ||
| self.last_failure = Some(now); | ||
| } |
Review catch. should_attempt treated a gap past the cap as a reset but record_failure kept incrementing, so a key quiet for longer than the cap was eligible to attempt as though its history were gone, then had its next failure counted as continuing the old streak — the delay jumped straight back to the cap and a consecutive-failure threshold fired on what was really the first failure of a new episode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
There was a problem hiding this comment.
🟡 Not ready to approve
The image-pull stale-resubmission path can race between overlapping pull tasks and corrupt PullState (in_flight/gate), potentially reintroducing hammering or concurrent pulls despite the new pacing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
crates/core/src/system/actuator/pull.rs:102
- When a pull is re-submitted due to staleness, the previous ("stale") tokio task can still complete later and mutate the shared
PullStatefor the newer attempt. In particular, a late failure from the old task will setstate.in_flight = falseand record a failure on the new state's gate, which can cause concurrent pulls and defeat the retry pacing (the next tick can spawn again while the newer task is still running). Similarly, a late success from an older task canremovethe map entry that a newer in-flight task expects to update.
To make stale resubmission safe, track an attempt generation/token in PullState (or compare against a captured started_at/attempt id) and, in the spawned task, only clear in_flight / update the gate / remove the entry if the token still matches the current state. Otherwise ignore the completion as belonging to an obsolete attempt.
tokio::spawn(async move {
let result = driver.container.pull_image(&image_owned).await;
let mut map = pulling_map.lock();
if let Err(e) = result {
if let Some(state) = map.get_mut(&image_owned) {
state.in_flight = false;
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Code Coverage OverviewLanguages: TypeScript, Rust TypeScript / code-coverage/vitestThe overall coverage in commit 84291bb in the Rust / code-coverage/rustThe overall coverage in commit 84291bb in the Show a code coverage summary of the most impacted files.
Updated |
There was a problem hiding this comment.
🟡 Not ready to approve
The image-pull stale-resubmission path can race with an older in-flight task and incorrectly clear in_flight, enabling unintended concurrent pull attempts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
crates/core/src/system/actuator/pull.rs:106
- The stale-pull resubmission path can spawn a second pull task for the same image while the original task is still running. When the original task later finishes, it unconditionally sets
state.in_flight = false(or removes the entry), which can incorrectly mark the newer attempt as not in flight and allow additional concurrent resubmissions on subsequent ticks. Capture an attempt identifier (e.g. thestarted_atInstant you set before spawning) and only mutate the map entry if it still refers to the same attempt.
let driver = Arc::clone(&self.driver);
let image_owned = image.to_owned();
let pulling_map = Arc::clone(&self.pulling);
tokio::spawn(async move {
let result = driver.container.pull_image(&image_owned).await;
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
The stale path resubmits while the earlier task is still running, and that task's completion then cleared in_flight and recorded a failure against the gate belonging to the attempt now in flight — so the next tick could spawn a third pull alongside it, which is the hammering the back-off exists to stop. Identify each attempt by the instant it was spawned at and ignore a completion that no longer matches. A success stays unconditional: the image is present however it got there. The completion path moves out of the spawn closure so it can be tested without a runtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtxQdsF6YhLzz9RrDmHEDv
Closes cross-cutting theme 7 from the logic bug audit: retry logic that either hammers or gives up forever. See the pattern analysis.
The class
Every retry site needs two things: a back-off, so failure does not mean hammering, and a recovery path, so failure does not mean death. Each audited site had at most one. Two distinct shapes hide under it, and they need different treatment — which is why a single "retry this future" helper (the
backoff-crate model) fits neither: tick-driven sites have no future to wrap, and relay loops need per-error classification mid-loop rather than whole-operation retry.(a) Tick-driven retries — a per-key decision
The reconciler is the loop; what was missing is a per-key "should I attempt now?".
runtime/retry.rsgeneralises the semanticsscheduler::should_back_offalready implements and tests against the persisted operations log (r[history.operations.rate-limiting]) — capped exponential from a base delay, with a gap longer than the cap resetting the count — for callers whose cadence is the tick rather than a row in that log, so the two do not drift apart. It has no exhausted state by construction: past a threshold the caller escalates to an operator-visible fault and keeps attempting at the cap interval. A permanent give-up is only legitimate behind an expiry or an explicit operator action with a reset path — the TLS retry-block plusstore::set_force_retrypair is the model.pull.rs(H3) retried a failed pull immediately on the next 5 s tick ("retry immediately", per the comment it deleted), then after 5 attempts setexhausted = true— which nothing could ever clear, because entries were removed from the map only on a successful pull, which could no longer be attempted. A registry that was briefly unreachable disabled actuation of the workload permanently, until the daemon restarted.PullStatenow holds a gate instead of an attempts counter and a terminal flag.issuance.rs(H10) dispatched Tailscale-discovered hostnames torun_tailscalebefore loading the unifiedcompute_statedecision, soBlockedandDebouncedwere never consulted. While tailscaled was down, every tick opened and finalised a failedtls_cert_attemptsrow — and the 1000-row cap inSnapshot::loadthen evicted other hostnames'last_attempt, dissolving their debounce too. The dispatch moves below the decision, and acheck_retry_gateapplies the two decisions that are about whether to attempt now rather than about ACME policy. The rule: a subsystem with a central decision function admits no dispatch before the decision.(b) Long-lived loops — classification, and a reported exit
A back-off alone would not have prevented these; they are misclassification plus silent exit.
udp_relay_task(H21) matchedOk(n) if n > 0and hit_ => breakfor bothOk(0)— a legal zero-length datagram — andErr(_), which is how an ICMP port-unreachable surfaces asECONNREFUSEDon a connected socket. One transient error killed the relay forever while the forward stayed registered and listed as healthy. Now: zero-length relays, transient errors report and continue, only connection loss ends it — and the exit is announced, because nothing else observes this task.forward.rs(H15) didif client.send_datagram(pkt).is_err() { break }, so quinn's recoverableSendDatagramError::TooLarge— any datagram over path MTU, so EDNS responses and QUIC payloads routinely — was treated asConnectionLost. The server relay already gets this right, so the two ends of one protocol disagreed.OiClient::send_datagramreturns quinn's error unboxed now (one caller), oversize datagrams are dropped and counted, and the count appears in the exit summary.Findings closed
On the dependency question
The theme analysis flagged this as needing a decision, since the repo prefers small dependencies over reimplementation. The obvious crates do not fit:
backoffis unmaintained, andexponential-backoff/backonmodel "retry this future or closure", which matches neither shape here. The repo already owns a tested implementation of exactly these semantics inshould_back_off, so this is that shape extracted rather than written fresh — but say if you would rather take a dependency and I will swap it.Enforcement
r[actuate.image.retry](successive failures spaced by an increasing bounded delay; a transient failure must not permanently disable actuation; the threshold is an escalation, not a terminal state, which is what allows the fault to clear on a later success) andi[forward.relay.resilience](a relay distinguishes a condition affecting one datagram from one affecting the forward; both ends classify identically; a relay that has ended is not reported as active).Not in scope
Misclassification itself — someone still has to decide that
Ok(0)is legal andTooLargeis per-packet, and a wrong call puts a transient error in the fatal arm regardless of machinery. Nor does this fix hangs upstream of a loop: the web event broker's back-off is correct but defeated bysubscribe_eventsparking insideaccept_uni, which is theme 1 (#137) — that PR is what makes this one's discipline reachable there. The 1000-rowSnapshot::loadattempt cap should become per-hostname-latest independently; this PR removes the flood that made it bite, not the cap.Overlap with other themes
Independent — sits on
main. Related to #137 only in that direction of causality, with no shared files.Generated by Claude Code