Conversation
…nreachable Two targeted resilience fixes in the reservation client, from an outside review of hand-rolled retry code. The review's broader proposal (replace all backoff with tenacity/stamina + httpx transport retries) is not adopted: the eleven next_attempt_at sites in main.py are requeue delays in a level-triggered reconciler — eight follow a guard denial where nothing raised and there is no call to re-drive — and their lack of an attempt cap is required, not a defect. Only these two sites were genuine. fetch_reservations: retry each page A network error or 5xx on page 3 of 5 aborted the whole refresh cycle, leaving the controller on stale reservation state for a full RESERVATION_FETCH_INTERVAL (300 s default). _get_reservations_page now retries such a failure up to 3 times with a 1 s / 2 s backoff, resuming at the same offset so earlier pages are not lost. A 4xx is not retried — a bad key or filter will not fix itself. A failure outliving the retries still raises: that fail-safe is load-bearing, since acting on a partial page set would wholesale-replace state.reservations with an under-count. create_ondemand_reservation: distinguish the failure modes The method collapsed a 409 denial and an unreachable app into a bare None, so _grant_and_admit gave both the same 2-5 min cooldown. It now returns a LeaseResult carrying a LeaseOutcome: DENIED (4xx) and MALFORMED (unparseable 2xx) keep the jittered cooldown, while UNAVAILABLE (network error or 5xx) takes the 30 s short retry, since the answer may differ the moment the app is reachable. Re-asking is safe because the request is idempotent on the pod UID. The cooldown split is bounded by the queue-processor tick — next_attempt_at gates eligibility only, so at the default 300 s interval both often resolve to "next tick". It bites when the interval is tuned down and on ADDED-triggered admission batches between ticks. No new dependencies, no new RBAC, no config changes. Docs: new attempt field and lease.denied reason enum in docs/LOG-FIELDS.md (duplicated verbatim in the reservation app — the sibling copy needs the same two edits), new api.reservations_fetch_retry event and updated lease.denied row in OBSERVABILITY.md, and a Transient-failure retry policy section in CLAUDE.md recording why the other nine sites stay as they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GSFUpM2bJUd5VDr8ibEsHT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two targeted resilience fixes in
reservation_client.py, arising from an outside review of hand-rolled retry code in this repo.Why not the reviewer's proposal
The review recommended replacing all backoff with
tenacity/staminaplushttpx.AsyncHTTPTransport(retries=...). That is not adopted, for three reasons:next_attempt_atsites inmain.pyare not call-retry loops. They are requeue delays in a level-triggered reconciler. Eight of them follow a guard denial (main.py:700,:711,:725,:745,:755— schedule verdict pending, stuck-holder interlock, class overcommitted, no single-node fit, unknown class id) where nothing raised and there is no call to re-drive. Retry libraries retry a callable that threw; that is not what these sites do.httpxtransportretries=only covers connection establishment — not read timeouts, not 5xx — so it could not have replaced the hand-rolled paths anyway.Two of the review's four cited sites (
controller_push.py,email_service.py) do not exist in this repo; they appear to describe the sibling reservation app. Of what remained, the two fixes below were genuine.Fix 1 — retry each page of
fetch_reservationsA network error or 5xx on page 3 of 5 aborted the entire refresh cycle, leaving the controller on stale reservation state for a full
RESERVATION_FETCH_INTERVAL(300 s default)._get_reservations_pagenow retries such a failure up to 3 times with a 1 s / 2 s backoff, resuming at the same offset so already-accumulated pages are not lost.state.reservationswith an under-count.Fix 2 — distinguish lease failure modes
create_ondemand_reservationcollapsed a 409 denial and an unreachable app into a bareNone, so_grant_and_admitgave both the same 2–5 min cooldown.It now returns a
LeaseResultcarrying aLeaseOutcome:GRANTEDDENIEDUNAVAILABLEMALFORMEDRe-asking on
UNAVAILABLEis safe because the request is idempotent on the pod UID — a lease created behind a lost response is handed back on the retry rather than duplicated.Scoping note, stated plainly: the cooldown split is bounded by the queue-processor tick.
next_attempt_atgates eligibility only, so at the defaultQUEUE_PROCESSOR_INTERVAL(300 s) a 30 s and a 2–5 min cooldown often both resolve to "next tick". The distinction bites when the interval is tuned down, and on ADDED-triggered admission batches that run between ticks. This is a real but modest improvement, not a large one.Testing
962 pass (baseline 951; 11 added). Both fixes were mutation-tested to confirm the new tests fail without them:
_FETCH_MAX_ATTEMPTS = 1fails all 4 retry testsNew coverage: retry on transient error and on 5xx, retry preserving earlier pages mid-pagination (asserts offsets
["0", "200", "200"]— the retry resumes rather than restarting), no-retry on 4xx, raise after exhausting attempts, all fourLeaseOutcomeclassifications, and the caller-side cooldown split.Docs
The log-grammar build gate (
tests/test_log_grammar.py) requires these:docs/LOG-FIELDS.md— newattemptfield, newlease.deniedreason enum.OBSERVABILITY.md— newapi.reservations_fetch_retryevent, updatedlease.deniedrow.CLAUDE.md— new Transient-failure retry policy section recording why the other nine sites stay as they are, so this question does not get re-litigated from scratch.No new dependencies, no new RBAC, no config changes.
Generated by Claude Code