[PARKED] fix(opal-client): honour Retry-After, stop retrying 409, and defer re-fetch after exhausted retries - #943
Closed
Zivxx wants to merge 7 commits into
Closed
[PARKED] fix(opal-client): honour Retry-After, stop retrying 409, and defer re-fetch after exhausted retries#943Zivxx wants to merge 7 commits into
Zivxx wants to merge 7 commits into
Conversation
…er exhausted retries
`PolicyFetcher.fetch_policy_bundle` drove tenacity with only `wait` + `stop` and
no `retry=` predicate, so every exception was retried identically: a 409 (the
scope's branch cannot be resolved) burned the same five attempts as a transient
503, `Retry-After` was never read, and after ~40s the fetch gave up for good.
`PolicyUpdater.update_policy` recorded the error and nothing re-scheduled, so a
client that asked for its bundle while the server was still cloning the repo sat
on a stale policy store until the next pub/sub message or WebSocket reconnect.
The server now answers `GET /scopes/{id}/policy` with a classified contract:
503 + `Retry-After: 30` (clone in progress), 503 + `Retry-After: 5` (clone
unavailable), and 409 (branch unresolved, no Retry-After). This teaches the
client to read it.
- classify responses in `_fetch_policy_bundle`: 503/429 -> `RetryableBundleError`
carrying the parsed `Retry-After`; 409 -> `NonRetryableBundleError`; 404 ->
`BundlePathNotFoundError`, which subclasses both `NonRetryableBundleError` and
fastapi's `HTTPException` so the retry predicate sees one type while callers
that caught `HTTPException` keep working. Everything else keeps its current
(retryable) behaviour via `throw_if_bad_status_code`.
- `parse_retry_after` handles delta-seconds and HTTP-date, clamps past/negative
values to 0, and returns None for anything malformed (including "nan"/"inf",
which parse as floats but are not usable sleep durations).
- `retry=retry_if_not_exception_type(NonRetryableBundleError)` so a 409/404 costs
exactly one request.
- `wait_retry_after_or_backoff` waits for whichever is longer, the server's hint
or the configured backoff, with the hint bounded by the new
POLICY_UPDATER_MAX_RETRY_AFTER (default 60s). The cap applies only to the
server-supplied value; POLICY_UPDATER_CONN_RETRY keeps its meaning.
- `PolicyUpdater` arms at most one deferred re-fetch when a fetch exhausts its
retries against a retryable error, cancelled by a successful fetch, by an
incoming policy update and by `stop()`, bounded by
POLICY_UPDATER_MAX_DEFERRED_ROUNDS (default 20) and gated by
POLICY_UPDATER_RESCHEDULE_ON_RETRYABLE (default True). It re-queues through
the existing update queue rather than calling `update_policy` directly, so the
re-fetch stays serialized with every other update instead of racing a second
policy-store transaction.
53 new tests; each new guard carries a comment naming the single-line mutation
it catches, and all 16 such mutations were verified to fail the suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds OPAL_POLICY_UPDATER_MAX_RETRY_AFTER, OPAL_POLICY_UPDATER_RESCHEDULE_ON_RETRYABLE and OPAL_POLICY_UPDATER_MAX_DEFERRED_ROUNDS to the OPAL Client "Policy Updates Configuration" section, next to OPAL_POLICY_UPDATER_CONN_RETRY whose semantics they extend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd budget, store-read failure HIGH-1: the deferred re-fetch had no jitter, so every client that hit the same outage came back on the same tick and re-created the stampede the backoff exists to prevent. The delay is now jittered into [delay/2, delay] via a module-level `_jittered` seam (tests replace it with the identity function). HIGH-2: honouring a large `Retry-After` inside the tenacity loop blocked the serial `_policy_update_queue` for up to attempts x cap — a proxy answering `Retry-After: 300` stalled every other policy update for minutes. The stop condition is now `stop_after_attempt(n) | stop_after_delay(MAX_RETRY_AFTER)`, so one fetch can never hold the queue longer than the cap; the deferred re-fetch, which does not hold the queue, owns the long horizon. MEDIUM-1: `POLICY_UPDATER_MAX_RETRY_AFTER=0` collapsed the deferred backoff to 0 and burned all 20 rounds back-to-back. The delay is now floored at DEFERRED_REFETCH_BASE_SECONDS: `max(min(max(retry_after, backoff), ceiling), 5)`. MEDIUM-2: the round budget reset on every WebSocket reconnect, because `_on_connect` calls `trigger_update_policy` which reset the counter — and reconnects are frequent during exactly the outages the budget is meant to bound, so MAX_DEFERRED_ROUNDS was unreachable. The reset now happens only on a genuine incoming policy update (`_update_policy_callback`) and on a successful fetch. `trigger_update_policy` still cancels the pending timer. MEDIUM-3: `get_policy_version()` ran outside the try in `update_policy`, so when the policy store was restarting alongside the server the exception escaped before the fetch and no deferral was ever armed. It now degrades to a full bundle fetch. MEDIUM-4: added an end-to-end test that drives the deferral through the real `handle_policy_updates` loop (fail 503 -> timer -> queue -> handler -> success). LOW-1: per-attempt classification (and the 404 "requested paths not found" line) dropped to debug; `fetch_policy_bundle` now emits exactly one classified WARNING per exhausted fetch, so a fleet-wide outage no longer multiplies log volume by the attempt count. LOW-2: `stop()` awaits the timer it cancelled, instead of leaving a half-cancelled task behind at shutdown. 17 new tests (36 -> 106 in the opal-client suite). The mutation script now covers 25 single-line mutations across both rounds; all 25 fail the suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he round budget Covers the review's LOW-6/7: MAX_RETRY_AFTER also bounds a whole fetch and ceilings the deferred backoff; 429 is retryable alongside 503; the deferred sequence is 5, 10, 20, 40, 60s jittered into [delay/2, delay]; the round budget is not reset by a reconnect, and the resulting worst case is stated explicitly (1035s of deferred waiting before jitter, i.e. ~8.5-17 minutes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate the operator's retry budget NEW-1: the deferred round budget was terminal — a client whose WebSocket never reconnects and whose scope never receives a commit stayed permanently stale after a long clone. The budget now bounds ESCALATION only: past POLICY_UPDATER_MAX_DEFERRED_ROUNDS the client keeps deferring at a flat `_jittered(POLICY_UPDATER_MAX_RETRY_AFTER)` cadence (30-60s at defaults, floored at 5s) until a fetch succeeds or a genuine policy update arrives. Entering the flat phase logs one warning, not one per round. POLICY_UPDATER_RESCHEDULE_ON_RETRYABLE=false still disables all deferral, including the flat phase. NEW-2: `stop_after_delay(cap)` silently truncated an operator's own POLICY_UPDATER_CONN_RETRY — with `wait_fixed(0.2) x 5` and a 0.3s cap, only 2 of the 5 configured attempts ran. The bound is now `max(cap, POLICY_UPDATER_CONN_RETRY.worstCaseTotalWait())`, a new method on ConnRetryOptions that uses max_wait for the exponential strategies and wait_time for fixed. The whole-fetch bound exists to stop a *server-supplied* hint from monopolising the serial update queue, not to shorten a deliberate configuration. NEW-4: the per-attempt "server connection error" line drops to debug, so a connection-refused outage also costs exactly one WARNING per exhausted fetch rather than one per attempt. NEW-5: as a consequence of NEW-2, POLICY_UPDATER_MAX_RETRY_AFTER=0 now means "honour no server hint" rather than "no retries" — pinned by a test. LOW-3: the status-classification comment now names its provenance (opal-server PR #924, scopes API) and notes that only the 503 path is exercised on older servers. Two existing tests were rewritten rather than deleted, because this changes their behaviour on purpose: `test_deferred_rounds_are_bounded` becomes `test_the_escalation_stops_at_the_round_budget`, and the stop-condition test now pins the max() semantics. The reconnect test pins OPAL_STATISTICS_ENABLED off so it does not depend on ambient config. 16 new tests (106 -> 122 in the opal-client suite). The mutation script now covers 31 single-line mutations across all three rounds; all 31 fail the suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the flat cadence NEW-3: POLICY_UPDATER_MAX_RETRY_AFTER now describes the same three roles in config.py and configuration.mdx (caps each honoured Retry-After; bounds the extra wall-clock time one fetch may spend beyond the operator's own backoff; ceilings the deferred backoff and flat cadence). The "does not clamp POLICY_UPDATER_CONN_RETRY" clause is gone from both — it contradicted the whole-fetch bound before NEW-2 and is redundant after it. NEW-1/NEW-5 docs: MAX_DEFERRED_ROUNDS is described as escalation rounds before the flat cadence, the "gives up / waits for a reconnect" wording is gone, and the steady-state rate is stated (one request every 30-60s per client, mean ~45s, i.e. about 1.3 per minute). The "setting it to 0 disables in-fetch waiting" sentence is corrected: 0 means "honour no server hint" and does not disable retries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… not attempts x max_wait
NEW-6: `ConnRetryOptions.worstCaseTotalWait()` estimated the budget as
`attempts x max_wait`. `max_wait` defaults to tenacity's MAX_WAIT (~4.6e18), so
an operator setting `{"wait_strategy": "exponential", "wait_time": 1,
"attempts": 5}` without pinning max_wait produced a budget of ~2.3e19 — and
since the whole-fetch bound is `max(cap, budget)`, the HIGH-2 protection that
keeps one fetch from monopolising the serial update queue silently disappeared.
Now the exact sum: `attempts` attempts are separated by `attempts - 1` waits;
exponential/random_exponential sum `min(wait_time * 2**i, max_wait)` over those
gaps, fixed uses `n_waits * wait_time`. The example above is 1+2+4+8 = 15s.
Implemented with a saturation break rather than the literal generator
expression: once the doubling reaches `max_wait` every remaining wait is
`max_wait`, so they are added in one step. That is not only cheaper — it is
required, because `wait_time * 2 ** i` raises OverflowError once i passes 1023
(a float base overflows there rather than saturating to inf), which a large
`attempts` would otherwise hit at client startup. Covered by a 3000-attempt
regression test.
At the shipped defaults the budget drops from 50.0 to 15.0 and the effective
bound is unchanged at max(60, 15) = 60.
4 new tests (122 -> 126 in the opal-client suite). The mutation script gains four
NEW-6 guards (coarse estimate, attempts-vs-gaps off-by-one, fixed-strategy gap
count, missing saturation break); all 34 mutations across the four rounds fail
the suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Deploy Preview for opal-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Zivxx
marked this pull request as draft
August 17, 2026 12:38
Contributor
Author
|
Parked as draft: we are taking the server-side approach in #924 (publish a policy notification when a clone becomes ready, and hold |
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.
Motivation
This is the client companion of server PR #924, which gave
GET /scopes/{id}/policya classified contract:503 + Retry-After: 30(clone in progress),503 + Retry-After: 5(clone vanished/corrupt),409(branch unresolved, noRetry-After),404(path not in repo).The client understands none of it.
PolicyFetcherdrives tenacity with onlywait+stopand noretry=predicate, so:409burns the same 5 attempts as a transient503;Retry-Afteris never read — the client backs off on its own schedule (wait_random_exponential(multiplier=1, max=10)), ignoring the server's estimate;PolicyUpdater.update_policyrecords the error into the store transaction and nothing re-schedules.A PDP that asks for its bundle while the server is still cloning therefore sits on a stale or empty policy store until the next pub/sub message or WebSocket reconnect — for a low-churn scope, hours.
Against a master server the
409path is inert (the server does not emit it yet); the503/Retry-Afterhandling pays off immediately, and the409handling lands ahead of #924.Behaviour
503+Retry-After: 30503+Retry-After: 5503, noRetry-After429Retry-Afterhonoured409branch unresolvedNonRetryableBundleError, no re-schedule404path missingHTTPExceptionHTTPExceptionRetry-After: 99999POLICY_UPDATER_MAX_RETRY_AFTER(60 s), per wait and per fetchConfiguration
Three new keys, all backwards-compatible defaults (
OPAL_prefix):POLICY_UPDATER_MAX_RETRY_AFTER60.0Retry-After, bounds the extra wall-clock time one fetch may spend beyond the operator's own backoff, and ceilings the deferred backoff and flat cadence.0= honour no server hint (does not disable retries).POLICY_UPDATER_RESCHEDULE_ON_RETRYABLETrueFalserestores the previous fire-and-forget behaviour.POLICY_UPDATER_MAX_DEFERRED_ROUNDS20The whole-fetch time bound is
max(POLICY_UPDATER_MAX_RETRY_AFTER, exact worst-case wait of POLICY_UPDATER_CONN_RETRY)— it exists to stop a server-supplied hint from monopolising the serial update queue, never to shorten a retry policy you configured on purpose.Deferred re-fetch
At most one pending timer at a time. Escalating phase: delay =
max(Retry-After, 5·2^(n−1)), clamped to[5 s, MAX_RETRY_AFTER], then jittered into[delay/2, delay]so a recovering fleet does not return in lockstep. That phase lasts5 + 10 + 20 + 40 + 16×60 = 1035 sbefore jitter (~8.5–17 min).After the budget the client does not give up — a client whose WebSocket never reconnects and whose scope never receives a commit would otherwise stay stale forever — it settles into a flat jittered cadence of one request every 30–60 s (mean ~45 s, ~1.3/min per client) until a fetch succeeds. Entering the flat phase logs one warning, not one per round.
Cancelled by a successful fetch, by an incoming policy update, and by
stop(). The round counter and flat phase are reset by a successful fetch and by a genuine policy update — deliberately not by a reconnect, since reconnects are frequent during exactly these outages and would make the escalation limit unreachable. The timer re-queues through the existing update queue rather than callingupdate_policydirectly, so it stays serialized with other updates instead of racing a second policy-store transaction.Logging
Exactly one WARNING per exhausted fetch (classified: retryable / non-retryable / connection error), one per deferral round, and one on entering the flat phase. Per-attempt classification, the 404 "requested paths not found" line, and the "server connection error" line are all
debug, so a fleet-wide outage does not multiply log volume by the attempt count.Tests
90 new tests (36 → 126 in the opal-client suite; full repo 326). No real sleeping except two deliberate real-clock tests for the whole-fetch bound (
stop_after_delayis wall-clock based, so a faked sleep would disarm them). Coverage:Retry-Afterdelta / HTTP-date / naive-date / malformed / negative /nan/inf; both caps and the operator-budget floor (exact sum of waits, saturating, no overflow at absurd attempt counts);409and404single-attempt;500and connection errors still retried;503→200recovery; non-JSON error bodies; log-level and warning-count contract; jitter envelope and spread; the 5 s floor; reconnect-vs-update round semantics; policy-store read failure; flat-phase engagement, once-only warning and clearing; and an end-to-end pass through the realhandle_policy_updatesloop.Each guard names in a comment the single-line mutation it catches. A script applies 34 such mutations and verifies every one fails the suite.
Compatibility
No default behaviour change beyond "stop hammering 409/404", "honour
Retry-After", and the new whole-fetch time bound.BundlePathNotFoundErroris still afastapi.HTTPExceptionwithstatus_code=404, so existing 404 handlers keep working.One visible change: the 404 exception's
repr()is nowBundlePathNotFoundError(status_code=404, detail=...)rather than fastapi'sHTTPException(...), and that string is what lands in the OPA transaction log'serrorfield — anything grepping forHTTPExceptionthere needs updating.Permit's PDP pins opal-client 0.9.6, so shipping this needs a PDP bump; the three keys can then be set per-PDP via
pdp_opal_client_config_overrides.Follow-up (out of scope)
A
404returned for an unknownbase_hash(the server no longer has the delta base) is currently non-retryable and waits for the next update; it could instead be recovered immediately with a forced full re-fetch. Worth a separate PR.🤖 Generated with Claude Code