Skip to content

feat(transport): bounded retry for active services, over an API-neutral layer - #350

Draft
thodson-usgs wants to merge 10 commits into
DOI-USGS:mainfrom
thodson-usgs:refactor/phase-2-transport-boundaries
Draft

feat(transport): bounded retry for active services, over an API-neutral layer#350
thodson-usgs wants to merge 10 commits into
DOI-USGS:mainfrom
thodson-usgs:refactor/phase-2-transport-boundaries

Conversation

@thodson-usgs

@thodson-usgs thodson-usgs commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Retitled. This started as a boundary extraction and was described as one,
but 3822f04f also switched WQP, NLDI, StreamStats, and Water Use onto a
retrying request path. That is a user-visible resilience change, not a
refactor, so it now leads the description. Reviewers should evaluate the
retry behavior on its own merits; the layering change is the second half.

What changes for users

Four services now retry transient failures where they previously failed on the
first attempt:
wqp, nldi, streamstats, and wateruse. A request that
hits a 429 or a gateway 5xx is re-sent up to API_USGS_RETRIES times (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, 0 disables) bounds how long
    a call may go without receiving any data. API_USGS_RETRIES counts
    attempts, 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).
  • Which statuses are re-sent is per-adapter. WQP answers an over-large query
    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.
  • Failures already settled are not retried — an unsupported scheme, a
    malformed request, or a hostname the resolver rejects outright fails on the
    first attempt. A temporary resolver failure (EAI_AGAIN: a resolver still
    coming up, a VPN reconnect, a laptop waking) stays retryable.
  • Backoff always includes jitter, including on a server-named Retry-After,
    so sub-requests handed one hint do not wake in lockstep, and a Retry-After
    of 0 cannot become a zero-delay re-send. An HTTP-date that has already
    passed 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 nwis is untouched — it still uses utils.query, which retains
its exact signature and performs no retry.

A bad setting now raises ConfigurationError (both a DataRetrievalError
and a ValueError), so a typo in API_USGS_RETRIES, API_USGS_STALL_TIMEOUT,
or API_USGS_CONCURRENT no longer escapes a request path as a bare
ValueError.

Measured against the live API

A 4-state, 30-year get_daily over 800 sites at parallel_chunks(1) runs 91.8 s
wall-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.transport is a new internal, API-neutral execution layer owning
guarded HTTP client lifecycle and timeout defaults, host-scoped authentication,
cursor pagination, bounded retry, response aggregation, progress, and
sync-over-async dispatch. dataretrieval.ogc keeps its protocol concerns:
dialects, CQL2, request construction, feature shaping, URL-byte chunk planning,
resumable ChunkedCall state, 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.liveness is a stdlib-only leaf recording when data last
arrived, 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.transport is internal and carries no API
promise.

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.query retains its exact signature and still
performs no retry.

Compatibility aliases are kept where a consumer exists: the private utils
transport names (_get, _default_headers, HTTPX_DEFAULTS, …) and the
ogc.engine wrappers are live re-exports, so existing monkeypatch sites keep
working. Two modules were removed rather than aliased, since nothing in the
tree imported them: dataretrieval.ogc.progress and
dataretrieval.ogc.combining (now dataretrieval.transport.progress /
dataretrieval.transport.combining). dataretrieval.ogc.retry keeps only its
OGC 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 use
  • 01268fd3 — bound retry by elapsed silence and failure kind
  • c1121265 — don't spend the stall budget on queue time
  • 2ce45372 — never let the budget withhold the first retry
  • 5c666d20 — credit the queue wait instead of resetting the clock
  • 99fdbe5d — give the retry loop the gate and the whole stop rule
  • 5dfa267b — scope the retry-status rule and stop over-classifying DNS
  • 8ed5889a — repair three Retry-After invariants review found broken

Validation

Re-run on the current tip (8ed5889a): full suite 666 passed with 57 expected
warnings; 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 later
commits): 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.py enforce transport
dependency direction, an acyclic transport graph, and Water Use isolation from
OGC. ADR 0006 records the decision.

Test-coverage caveat

tests/conftest.py::_pin_chunker_env pins API_USGS_RETRIES=0 for the whole
suite, 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)

  • .retryable on the exception taxonomy stays the broader "might succeed" hint
    and 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_error still maps some deterministic failures to a
    resumable ServiceInterrupted (pre-existing).
  • Three next-cursor validators exist with three failure modes (wateruse,
    ogc/engine, waterdata/ratings); a shared resolver belongs in transport.
  • The stall default equals the 60 s read timeout, so a request burning a full
    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.

@thodson-usgs
thodson-usgs force-pushed the refactor/phase-2-transport-boundaries branch from 6a57e29 to 3822f04 Compare August 3, 2026 15:25
thodson-usgs and others added 6 commits August 3, 2026 15:03
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
@thodson-usgs thodson-usgs changed the title refactor(transport): extract API-neutral execution policy feat(transport): bounded retry for active services, over an API-neutral layer Aug 4, 2026
thodson-usgs and others added 2 commits August 4, 2026 11:49
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
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