feat(transport): bounded retry for active services, over an API-neutral layer - #350
Draft
thodson-usgs wants to merge 10 commits into
Draft
feat(transport): bounded retry for active services, over an API-neutral layer#350thodson-usgs wants to merge 10 commits into
thodson-usgs wants to merge 10 commits into
Conversation
thodson-usgs
force-pushed
the
refactor/phase-2-transport-boundaries
branch
from
August 3, 2026 15:25
6a57e29 to
3822f04
Compare
Opting WQP, NLDI, StreamStats, and Water Use into shared retry made several failures cost far more than they did before. Every NetworkError was retryable, so a 60 s read timeout became five silent minutes; every 5xx was retried, so a query the service had rejected outright was sent five times; and a Retry-After date that had already passed clamped to zero, turning backoff into a burst. Retry now needs two independent bounds to allow another attempt: the attempt count, and a no-progress budget (API_USGS_STALL_TIMEOUT, default 60 s) measured since data last arrived. Attempts alone leave elapsed time unbounded because each attempt may block until its own timeout; the budget alone would cut short a slow but productive download, so every page received restarts it. An attempt already in flight is never interrupted. Only failures a later attempt could survive are re-sent: 429 and the gateway 5xx family, and transport failures that are not already settled (an unresolvable host or unsupported scheme is not). Backoff always includes jitter, even on a server-named delay, so concurrent sub-requests handed one hint no longer wake in lockstep. A Retry-After date beyond the wait cap is treated as no hint rather than as "give up", since it is more likely clock skew than intent. Also: a bad setting now raises ConfigurationError (a DataRetrievalError and a ValueError) instead of escaping request paths as a bare ValueError; the API-key hint is shown only for the host that honors the key; Water Use normalizes its next-page cursor by host rather than by one literal prefix; and the ogc.progress / ogc.combining re-export shims and ogc.retry's by-value tunable copies are deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
The no-progress budget started when a retry loop was entered, but a fan-out task enters its loop at dispatch and may then sit behind the concurrency gate for a long time. The tail of a wide chunked call or Water Use fan-out therefore began its first attempt with the budget already spent and got zero retries, while the sub-requests dispatched ahead of it got the full allowance. Acquiring a slot now counts as progress: waiting for a turn is not silence. Both Retry-After spellings are also treated alike again. Discarding an over-long HTTP-date hint made the client retry harder against a service that had just asked for a long pause, and dropped the number the caller needs from .retry_after; an inflated date costs a recoverable escalation, which is the cheaper mistake. Jitter on a server hint is now a small decorrelating nudge rather than full exponential jitter, which could stack 30 s on top of a hint already at the 60 s cap. Also: the Water Use cursor rewrite drops an explicit port along with the scheme, so an http://...:8080 next link can no longer be dialed under TLS on 8080; waterdata.stats borrows an active chunked call's shared client instead of opening its own; and the stall_timeout docs now state plainly that the default equals the read timeout, so a fully timed-out request is not retried while faster failures still are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
Measured against the live API, a multi-state 30-year get_daily pull at parallel_chunks(1) runs 92 s wall-clock but its longest silence is 12.1 s: the budget measures gaps between pages, not duration, so a long successful query was never at risk. 12 s against a 60 s budget is only 5x headroom though, and a single heavy page against a loaded service -- or any attempt that runs to the read timeout -- can spend the whole budget alone. That would surface a transient immediately, disabling retry for exactly the large queries that most need it. The budget now bounds repeated silence only: the first retry is always allowed, and the budget decides whether to continue after that. A dead connection costs about two read timeouts (~2 min, verified) rather than five attempts (~5 min). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
The slot stamp added in c112126 fired on every attempt, not only queued ones: the gated body is exactly what the retry loop re-invokes, so each attempt restarted the no-progress clock and discarded silence accumulated by earlier attempts. That quietly turned the cumulative bound into a per-attempt latency bound on the chunked and fan-out paths -- five 45 s failures each looked like 45 s of silence rather than 225 s -- reinstating on those paths the multi-minute hang the budget exists to prevent, while the sync path kept the intended two attempts. The gate now credits only the measured wait, shifting the stamp forward by it rather than to "now". Verified through the real ChunkedCall with an uncontended gate: 3 attempts / 1.24 s of silence against a 1.0 s budget, matching the sync path, where before it was 5 attempts / 4.09 s. The queue-time fix and its regression test still hold. Also hold a jittered Retry-After to retry_after_cap, so a hint already at the cap can no longer be nudged past the longest wait the policy declares. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
…rule Cleanup pass over the retry hardening. Three things sat at the wrong layer. The concurrency gate was wrapped by each adapter, so both restated in prose the same invariants -- acquire per attempt, credit the wait rather than restamp -- that transport never stated in code. A third adapter writing a plain `async with semaphore` would silently degrade the budget on its fan-out tail with nothing to catch it. `retry_async` now takes the semaphore and owns both rules; liveness goes back to being a fact leaf (`note_progress`, `elapsed_since_progress`, `credit_wait`) that no adapter imports. The stall rule was half on the policy and half an `attempt > 1` special case in the orchestrator -- the field docstring had to point outward at its own caller to explain itself. It is now `RetryPolicy.allows_wait`, pure because the caller passes `elapsed` in, so the dataclass is again a complete description of when retry stops. `_RETRYABLE_STATUSES` moves to `transport.retry` beside the other retry tunables. It encodes our auto-re-send policy, not what a status means, and this branch's own ADR amendment says transport.retry is where retry tunables live. Also: `API_USGS_CONCURRENT` now raises ConfigurationError like the other two retrieval env vars; trimmed rationale that was argued three times in one file; dropped a stall test strictly dominated by its neighbour and a backoff test duplicated from the transport suite; recorded the stats.py -> ogc.chunking client-borrow coupling as architecture debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
… DNS Three defects from review, all in the new retry policy rather than the extraction. The narrowed retryable-status set was written for WQP and StreamStats, whose services answer a *bad query* with a 500, but it applied to every caller -- so a 500 during a chunked Water Data getter stopped being retried inline and started escalating straight to ChunkInterrupted. For a query interface like the OGC API a 500 is an upstream hiccup, and riding one out is what the chunker is for. The set is now a policy field: the default keeps every 5xx, and the one-shot adapters opt into the gateway-only set. `_deterministic_failure` treated every `socket.gaierror` as permanent, but that class covers EAI_AGAIN -- a resolver still coming up, a VPN reconnect, a laptop waking. Those failed immediately even with retries configured, where before this branch they were retried. Only the known-permanent codes are deterministic now; an unrecognized code stays retryable, since a wasted retry is cheaper than a dropped call. The `get_active_client()` borrow in waterdata/stats.py was unreachable and would have been unsafe if reached: the chunker publishes its client inside its own portal loop, while stats runs in a fresh one, so the branch always resolved to None and driving that pool across loops would corrupt it. Reverted, with the reason recorded on the parameter, and the architecture-debt entry it prompted dropped along with it. Also: `inf`/`nan` no longer slip through `Retry-After` parsing or env validation, where they poisoned every later comparison; a comment no longer refers to the deleted `fetch_gated`; and a test that had been spliced into the middle of a two-line comment no longer orphans its second half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
An already-elapsed HTTP-date was read as "retry now" (clamped to 0.0).
Literally that is what it says, but the likelier cause is our clock
running ahead of the server's, and acting on it re-sends almost
immediately against a service that just asked for a pause. It now yields
no hint at all, falling back to our own bounded backoff -- right under
either reading. Delta-seconds is clock-independent, so a literal
Retry-After: 0 is still honored as the instruction it is.
The "a hint of 0 can never become a zero-delay re-send" invariant was
false whenever base_backoff was zero: the jitter nudge was bounded by the
attempt's exponential ceiling, which is zero exactly then. It is now
bounded by max_backoff, so a policy that declares no backoff at all still
gets none, while every policy that declares some gets its floor.
`retryable_statuses or _RETRYABLE_STATUSES` collapsed an explicitly empty
frozenset -- "never re-send on any status" -- into the widest set. Latent,
since no caller passes one, but a footgun on a constructor argument.
Also: a refused Water Use cursor raised a bare RuntimeError, outside the
DataRetrievalError taxonomy the package promises, which paginate then
wrapped in generic advice ("retry, reduce the request size, obtain an API
token") that cannot fix a cross-host link. It is now typed and says what
actually happened. And Water Use's use of the broad status set is now
explained where it is chosen: NWDC reports a bad query as a 400, so
unlike WQP and StreamStats its 5xx is a real upstream fault.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
CI was red on Ubuntu. The test built its `socket.gaierror` with a literal errno of 8, which is `EAI_NONAME` on macOS but -2 on Linux and something else again on Windows, so the code correctly refused to treat 8 as a permanent resolver failure anywhere but the machine the test was written on. The production lookup was already platform-correct -- it reads the constants from `socket` -- so this is the test, not the classifier. The permanent and temporary cases now come from `socket.EAI_NONAME` and `socket.EAI_AGAIN`, via a helper that builds the wrapper chain once. That also gave the `EAI_AGAIN` path its first test: "try again" is exactly the failure retry exists for, and nothing pinned it. Also pin the test step to bash on every OS. Windows defaults to PowerShell, which does not stop on a failing native command and takes the step's exit code from the last one -- so `coverage report -m` running after a failed pytest reported success. The Windows matrix was green for this same failure: its log says "1 failed, 665 passed" under a passing job. Every Windows test failure has been invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UVSiGgUyDRSbqNWM5HBbL
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.
What changes for users
Four services now retry transient failures where they previously failed on the
first attempt:
wqp,nldi,streamstats, andwateruse. A request thathits a 429 or a gateway 5xx is re-sent up to
API_USGS_RETRIEStimes (default 4)with exponential backoff and full jitter, honoring a server
Retry-After.This costs latency and quota on failing requests, so the retry is bounded on two
independent axes and narrowed to failures a later attempt could actually
survive:
API_USGS_STALL_TIMEOUT(new; default 60 s,0disables) bounds how longa call may go without receiving any data.
API_USGS_RETRIEScountsattempts, not seconds, so on its own four retries of a request that times out
after a minute is four silent minutes. Progress restarts the budget — a page
received, or a queued sub-request acquiring its concurrency slot — and an
attempt already in flight is never interrupted. The first retry is never
withheld, so one slow attempt cannot disable retry by itself. A dead
connection costs about two read timeouts (~2 min) instead of five attempts
(~5 min).
with a 500 and StreamStats answers out-of-network coordinates with one, so
those one-shot adapters re-send only for 429/502/503/504. The Water Data OGC
API is a query interface where a 500 is an upstream hiccup, so the chunker
keeps re-sending for every 5xx, as it always has.
malformed request, or a hostname the resolver rejects outright fails on the
first attempt. A temporary resolver failure (
EAI_AGAIN: a resolver stillcoming up, a VPN reconnect, a laptop waking) stays retryable.
Retry-After,so sub-requests handed one hint do not wake in lockstep, and a
Retry-Afterof 0 cannot become a zero-delay re-send. An
HTTP-datethat has alreadypassed is treated as no hint rather than as "retry now", since the likelier
cause is client/server clock skew.
Water Data (
waterdata) already retried via the chunker and keeps doing so;what is new for it is the stall budget, the deterministic-failure
classification, and the jitter above.
Deprecated
nwisis untouched — it still usesutils.query, which retainsits exact signature and performs no retry.
A bad setting now raises
ConfigurationError(both aDataRetrievalErrorand a
ValueError), so a typo inAPI_USGS_RETRIES,API_USGS_STALL_TIMEOUT,or
API_USGS_CONCURRENTno longer escapes a request path as a bareValueError.Measured against the live API
A 4-state, 30-year
get_dailyover 800 sites atparallel_chunks(1)runs 91.8 swall-clock and returns 581,070 rows. Its longest gap between progress events is
12.1 s — the budget measures silence, not duration, so long successful queries
are not at risk. A 2,000-site / 35-year variant: 74.0 s total, 11.2 s worst gap.
The layering change
dataretrieval.transportis a new internal, API-neutral execution layer owningguarded HTTP client lifecycle and timeout defaults, host-scoped authentication,
cursor pagination, bounded retry, response aggregation, progress, and
sync-over-async dispatch.
dataretrieval.ogckeeps its protocol concerns:dialects, CQL2, request construction, feature shaping, URL-byte chunk planning,
resumable
ChunkedCallstate, and interruption types.This is what makes the retry change tractable: before it, generic execution
behavior lived under OGC even where non-OGC services used it, so Water Use
depended on private protocol modules and retry policy was uneven across
services. There is now one retry policy to reason about instead of several.
Internally,
transport.livenessis a stdlib-only leaf recording when data lastarrived, so the page loop that observes progress and the retry loop that acts on
it both depend on it rather than on each other.
Not a public framework:
dataretrieval.transportis internal and carries no APIpromise.
Compatibility
Public imports, service signatures, return shapes, metadata, deprecations,
exception types, OGC chunking/resume behavior, and the exact four-symbol OGC
facade are unchanged.
utils.queryretains its exact signature and stillperforms no retry.
Compatibility aliases are kept where a consumer exists: the private
utilstransport names (
_get,_default_headers,HTTPX_DEFAULTS, …) and theogc.enginewrappers are live re-exports, so existing monkeypatch sites keepworking. Two modules were removed rather than aliased, since nothing in the
tree imported them:
dataretrieval.ogc.progressanddataretrieval.ogc.combining(nowdataretrieval.transport.progress/dataretrieval.transport.combining).dataretrieval.ogc.retrykeeps only itsOGC interruption classifiers; the retry tunables it used to re-export were
copies by value that patching could not reach, and now live solely in
dataretrieval.transport.retry.What changes deliberately is the retry behavior described above.
Commits
3822f04f— extract API-neutral execution policy (also the retry opt-in)966bb618— simplify shared policy use01268fd3— bound retry by elapsed silence and failure kindc1121265— don't spend the stall budget on queue time2ce45372— never let the budget withhold the first retry5c666d20— credit the queue wait instead of resetting the clock99fdbe5d— give the retry loop the gate and the whole stop rule5dfa267b— scope the retry-status rule and stop over-classifying DNS8ed5889a— repair threeRetry-Afterinvariants review found brokenValidation
Re-run on the current tip (
8ed5889a): full suite 666 passed with 57 expectedwarnings; strict mypy clean over 41 source files; Ruff lint and format clean;
all configured pre-commit hooks pass.
From the post-rebase validation of
966bb618(not re-run for the latercommits): coverage 97%, Sphinx HTML build with the same four pre-existing
warnings, isolated wheel build/install/import outside the checkout, and the
GitHub Actions matrix across lint, typing, artifact, docs, Ubuntu, and Windows.
Architecture fitness functions in
tests/architecture_test.pyenforce transportdependency direction, an acyclic transport graph, and Water Use isolation from
OGC. ADR 0006 records the decision.
Test-coverage caveat
tests/conftest.py::_pin_chunker_envpinsAPI_USGS_RETRIES=0for the wholesuite, so the newly default-on retry paths are exercised only where a test opts
back in explicitly. The green suite therefore verifies less of this behavior
change than its size suggests; the live-API measurements above and the targeted
retry tests are the real evidence.
Known follow-ups (recorded, not fixed here)
.retryableon the exception taxonomy stays the broader "might succeed" hintand does not agree with the stricter auto-re-send sets; unifying them changes
a documented public field and belongs in its own change.
ogc.retry._classify_chunk_errorstill maps some deterministic failures to aresumable
ServiceInterrupted(pre-existing).wateruse,ogc/engine,waterdata/ratings); a shared resolver belongs in transport.read timeout gets one retry rather than several. Deliberate.
Scope
Collection-family splitting, additional adapter restructuring, and legacy NWIS
retirement remain separate follow-up work.