Skip to content

Commit 6a57e29

Browse files
committed
refactor(transport): extract API-neutral execution policy
1 parent 8cbdcb5 commit 6a57e29

41 files changed

Lines changed: 1720 additions & 1389 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/python-package.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ jobs:
5353
from pathlib import Path
5454
5555
import dataretrieval
56+
import dataretrieval.transport
5657
from dataretrieval import ngwmn, waterdata, wateruse
5758
from dataretrieval.ogc import engine
5859

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
**08/02/2026:** Added an internal API-neutral transport layer for guarded HTTP clients, host-scoped authentication, cursor pagination, bounded retry, response aggregation, progress, and sync-over-async dispatch. Water Use and the non-OGC Statistics API now consume transport directly instead of private OGC execution helpers; WQP, NLDI, and StreamStats opt into bounded transient retry while deprecated NWIS behavior remains unchanged. OGC retains CQL2, request construction, feature shaping, chunk planning, resumable calls, and interruption types, with compatibility imports at previous private paths. Failed pagination and fan-out still raise rather than returning partial data, and no public signatures or return shapes changed.
2+
13
**08/02/2026:** Phase 1 OGC boundary stabilization: the `dataretrieval.ogc` package now exposes a deliberate, small facade (`OgcDialect`, `prepare_request_args`, `get_ogc_data`, `fetch_ogc_request`) that service adapters (NGWMN, Water Data's generic wrapper) import. Internal request-construction helpers moved to a new `ogc.requests` module; the dialect type and endpoint constants live in the leaf `ogc.policy` module. `ogc.shaping` no longer depends on `ogc.engine` at all, and the complete runtime OGC import graph is now acyclic. `_default_headers` now accepts a target URL and adds `X-Api-Key` only for `api.waterdata.usgs.gov`; shared sync and async clients also strip the key before following any cross-host redirect, including external rating-asset downloads. `waterdata.utils` no longer bulk-re-exports private OGC symbols, and consumers import implementation helpers from their canonical modules. No public API, return-value, or deprecation changes.
24

35
**08/02/2026:** Fixed source-distribution and wheel package discovery so the `dataretrieval.ogc` and `dataretrieval.waterdata` subpackages are included in installed artifacts. CI now builds and installs the wheel outside the source checkout before importing the core service modules. Added an architecture baseline, initial decision records, and executable dependency-direction guardrails for the existing modular-monolith boundaries.

dataretrieval/nldi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from json import JSONDecodeError
44
from typing import Any, Literal, cast
55

6-
from dataretrieval.utils import query
6+
from dataretrieval.utils import _query_with_retry
77

88
try:
99
import geopandas as gpd
@@ -23,7 +23,7 @@ def _query_nldi(
2323
# A helper function to query the NLDI API. ``query()`` already raises a
2424
# typed ``DataRetrievalError`` for any HTTP error response, so a returned
2525
# response is a success that we only need to parse.
26-
response = query(url, payload=query_params)
26+
response = _query_with_retry(url, payload=query_params)
2727
response_data: dict[str, Any] | list[Any] = {}
2828
try:
2929
response_data = response.json()

dataretrieval/ogc/__init__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
- :func:`fetch_ogc_request` — execute a pre-built request with pagination.
99
1010
Service adapters (NGWMN, Water Data's generic wrapper) import from this
11-
facade rather than reaching into engine internals. The engine module remains
12-
available for lower-level orchestration needs (e.g. ``_paginate``,
13-
``_run_sync``) that sibling modules like ``wateruse`` use under the accepted
14-
temporary variance.
11+
facade rather than reaching into engine internals. Generic execution policy
12+
lives in :mod:`dataretrieval.transport`; the engine retains compatibility
13+
wrappers at previous private paths.
1514
"""
1615

1716
from dataretrieval.ogc.engine import fetch_ogc_request, get_ogc_data

dataretrieval/ogc/chunking.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,13 @@
1717
1818
This module owns the *execution* half — the event loop and bounded
1919
concurrency that drive a plan to completion (``ChunkedCall``) plus the
20-
public ``multi_value_chunked`` decorator. The neighboring concerns live in
21-
sibling modules it imports, each with its own reason to change:
22-
:mod:`~dataretrieval.ogc.planning` builds the
23-
:class:`~dataretrieval.ogc.planning.ChunkPlan` and recombines per-chunk
24-
frames and responses (pure, no I/O); :mod:`~dataretrieval.ogc.retry` holds
25-
the transient-classification and exponential-backoff policy; and
20+
public ``multi_value_chunked`` decorator. The neighboring concerns remain
21+
separate: :mod:`~dataretrieval.ogc.planning` builds the
22+
:class:`~dataretrieval.ogc.planning.ChunkPlan`;
23+
:mod:`~dataretrieval.transport.combining` assembles results;
24+
:mod:`~dataretrieval.transport.retry` owns bounded retry policy; and
2625
:mod:`~dataretrieval.ogc.interruptions` defines the resumable
27-
:class:`~dataretrieval.ogc.interruptions.ChunkInterrupted` exception
28-
contract.
26+
:class:`~dataretrieval.ogc.interruptions.ChunkInterrupted` contract.
2927
3028
Concurrency: ``multi_value_chunked`` fans every pending sub-request out
3129
under one ``asyncio.gather`` sharing a single ``httpx.AsyncClient``. An
@@ -83,23 +81,19 @@
8381
import pandas as pd
8482
from anyio.from_thread import start_blocking_portal
8583

86-
from dataretrieval.utils import HTTPX_ASYNC_DEFAULTS, Ambient, _require_positive_int
87-
88-
from . import progress as _progress
89-
from .combining import (
84+
from dataretrieval.transport import progress as _progress
85+
from dataretrieval.transport.combining import (
9086
_combine_chunk_frames,
9187
_combine_chunk_responses,
9288
)
93-
from .interruptions import (
94-
ChunkInterrupted,
95-
)
89+
from dataretrieval.transport.http import open_async_client
90+
from dataretrieval.transport.retry import _NO_RETRY, RetryPolicy
91+
from dataretrieval.transport.retry import retry_async as _retry
92+
from dataretrieval.utils import Ambient, _require_positive_int
93+
94+
from .interruptions import ChunkInterrupted
9695
from .planning import ChunkPlan
97-
from .retry import (
98-
_NO_RETRY,
99-
RetryPolicy,
100-
_classify_chunk_error,
101-
_retry,
102-
)
96+
from .retry import _classify_chunk_error
10397

10498
# Empirically the API replies HTTP 414 above ~8200 bytes of full URL —
10599
# matches nginx's default ``large_client_header_buffers`` of 8 KB. 8000
@@ -650,7 +644,7 @@ async def _run(self, max_concurrent: int | None) -> tuple[pd.DataFrame, Any]:
650644
self.plan.total if max_concurrent is None else max_concurrent
651645
)
652646

653-
async with httpx.AsyncClient(limits=limits, **HTTPX_ASYNC_DEFAULTS) as client:
647+
async with open_async_client(limits=limits) as client:
654648
with _chunked_client(client):
655649
reporter = _progress.current()
656650
if reporter is not None:

dataretrieval/ogc/combining.py

Lines changed: 21 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,206 +1,21 @@
1-
"""Result recombination: merge per-chunk frames and responses (no I/O).
2-
3-
These utilities assemble the output of a chunked/fan-out call from its
4-
individual per-sub-request results. They have no event-loop, retry, or
5-
network state — they're pure data transforms imported by both the
6-
chunked-call execution (:mod:`dataretrieval.ogc.chunking`) and the
7-
per-page pagination (:mod:`dataretrieval.ogc.engine`).
8-
9-
Separated from :mod:`dataretrieval.ogc.planning` so that module stays
10-
focused on *what* to split, while this module owns *how* to reassemble.
11-
"""
12-
13-
from __future__ import annotations
14-
15-
import copy
16-
from datetime import timedelta
17-
18-
import httpx
19-
import pandas as pd
20-
21-
# Response header USGS uses to advertise remaining hourly quota. Lives in this
22-
# module so every layer (the combine helpers below, the engine's per-page
23-
# progress reporter) reads it from one place rather than hard-coding the string.
24-
_QUOTA_HEADER = "x-ratelimit-remaining"
25-
26-
27-
def _safe_elapsed(response: httpx.Response) -> timedelta:
28-
"""
29-
Read ``response.elapsed``, falling back to ``timedelta(0)`` when
30-
the attribute hasn't been populated.
31-
32-
httpx only writes ``.elapsed`` when a response is closed through
33-
its normal transport path. ``MockTransport`` (used by
34-
``pytest-httpx``) and hand-constructed ``httpx.Response`` objects
35-
leave the attribute unset, so accessing it raises ``RuntimeError``.
36-
Combining responses across chunks needs a defined duration, so we
37-
treat the missing attribute as zero elapsed.
38-
"""
39-
try:
40-
elapsed: object = response.elapsed
41-
except RuntimeError:
42-
return timedelta(0)
43-
return elapsed if isinstance(elapsed, timedelta) else timedelta(0)
44-
45-
46-
def _set_response_url(response: httpx.Response, url: str | httpx.URL) -> None:
47-
"""
48-
Overwrite the URL surfaced by a response without back-propagating
49-
the change into any aliased original.
50-
51-
Lightweight test doubles expose ``.url`` as a writable attribute. Real
52-
:class:`httpx.Response` objects resolve it through a bound request, so swap
53-
in a fresh request carrying the new URL; mutating the existing request would
54-
leak through any shallow copy that shares it.
55-
"""
56-
if not isinstance(response, httpx.Response):
57-
# Lightweight test doubles expose ``url`` as a writable attribute.
58-
response.url = url
59-
return
60-
61-
target = httpx.URL(str(url))
62-
try:
63-
old = response.request
64-
except RuntimeError:
65-
# No request bound (some hand-built httpx.Response fixtures);
66-
# synthesize a minimal one to hold the URL.
67-
response.request = httpx.Request("GET", target)
68-
return
69-
response.request = httpx.Request(method=old.method, url=target, headers=old.headers)
70-
71-
72-
def _lowest_remaining(responses: list[httpx.Response]) -> httpx.Response:
73-
"""The response reporting the lowest ``x-ratelimit-remaining``.
74-
75-
Within a rate-limit window, the counter decreases monotonically, so the
76-
smallest value observed is the most conservative value to surface. Under
77-
concurrent fan-out, the last response *by index* need not be the one the
78-
server processed last. Fall back to the last response when none reports
79-
the header.
80-
"""
81-
best: httpx.Response | None = None
82-
best_remaining: int | None = None
83-
for response in responses:
84-
try:
85-
remaining = int(response.headers[_QUOTA_HEADER])
86-
except (KeyError, ValueError):
87-
continue
88-
if best_remaining is None or remaining < best_remaining:
89-
best, best_remaining = response, remaining
90-
return best if best is not None else responses[-1]
91-
92-
93-
def _merge_response(
94-
base: httpx.Response,
95-
*,
96-
headers_from: httpx.Response,
97-
elapsed: timedelta,
98-
url: str | httpx.URL | None = None,
99-
) -> httpx.Response:
100-
"""Fold several responses into one: a shallow copy of ``base`` whose
101-
``.headers`` are rebuilt as a fresh ``httpx.Headers`` from ``headers_from``,
102-
``.elapsed`` set to ``elapsed``, and ``.url`` overridden when ``url`` is
103-
given. ``base`` and ``headers_from`` are never mutated, and the fresh
104-
``httpx.Headers`` means downstream mutations don't back-propagate into any
105-
underlying response — so callers may re-fold idempotently. This is the one
106-
low-level merge behind both pagination
107-
(:func:`~dataretrieval.ogc.engine._paginate`) and the chunked / fan-out
108-
aggregation (:func:`_combine_chunk_responses`)."""
109-
merged = copy.copy(base)
110-
merged.headers = httpx.Headers(headers_from.headers)
111-
merged.elapsed = elapsed
112-
if url is not None:
113-
_set_response_url(merged, url)
114-
return merged
115-
116-
117-
def _combine_chunk_frames(frames: list[pd.DataFrame]) -> pd.DataFrame:
118-
"""Concatenate per-chunk frames and deduplicate IDs across chunks.
119-
120-
Empty frames are ignored before concatenation so an empty plain
121-
:class:`pandas.DataFrame` cannot downgrade a real ``GeoDataFrame`` and
122-
strip its geometry or CRS. When every frame is empty, the first frame is
123-
returned to preserve its concrete type.
124-
125-
When multiple non-empty frames are combined, non-null feature IDs are
126-
deduplicated regardless of the plan axis. Filter clauses can match the same
127-
feature, and list inputs can contain repeated values or otherwise select
128-
overlapping records. Rows without an ``id`` are preserved verbatim: pandas
129-
treats null values as duplicates, so deduplicating them would silently lose
130-
data.
131-
"""
132-
non_empty = [frame for frame in frames if not frame.empty]
133-
if not non_empty:
134-
return frames[0] if frames else pd.DataFrame()
135-
if len(non_empty) == 1:
136-
return non_empty[0].copy()
137-
138-
combined = pd.concat(non_empty, ignore_index=True)
139-
if "id" not in combined.columns:
140-
return combined
141-
142-
has_id = combined["id"].notna()
143-
if has_id.all():
144-
return combined.drop_duplicates(subset="id", ignore_index=True)
145-
if has_id.any():
146-
id_rows = combined[has_id].drop_duplicates(subset="id")
147-
no_id_rows = combined[~has_id]
148-
return pd.concat([id_rows, no_id_rows], ignore_index=True)
149-
return combined
150-
151-
152-
def _combine_chunk_responses(
153-
responses: list[httpx.Response], canonical_url: str | None
154-
) -> httpx.Response:
155-
"""
156-
Fold per-sub-request responses into a single aggregated response.
157-
158-
For a multi-response input, returns a shallow copy of
159-
``responses[0]`` with ``.headers`` set to those of the response reporting
160-
the lowest ``x-ratelimit-remaining`` value (the most conservative quota
161-
observation; see :func:`_lowest_remaining`), ``.elapsed`` set to the sum of
162-
the per-response elapsed durations, and ``.url`` set to the
163-
canonical original-query URL (when supplied) so ``BaseMetadata``
164-
reflects the user's full request rather than the first chunk.
165-
166-
For a single-response input with no canonical-URL override,
167-
``responses[0]`` is returned unchanged to skip the copy on the
168-
passthrough hot path.
169-
170-
Parameters
171-
----------
172-
responses : list[httpx.Response]
173-
One response per completed sub-request, in caller-provided order.
174-
canonical_url : str or None
175-
URL of the unchunked original request. ``None`` skips the URL
176-
override — used by the passthrough path (the fetcher's
177-
response already carries the original-query URL) and by the
178-
worst-case overflow path (no buildable canonical URL exists).
179-
180-
Returns
181-
-------
182-
httpx.Response
183-
A shallow copy of the first response with aggregated
184-
``headers``, ``elapsed``, and ``url``. The function is
185-
idempotent (the input responses' ``headers`` / ``elapsed`` /
186-
``url`` are never mutated), so it's safe to call repeatedly
187-
via :attr:`ChunkedCall.partial_response` during error
188-
inspection or resume retries. ``headers`` on the returned
189-
object is a fresh ``httpx.Headers``, so mutations there don't
190-
back-propagate into any chunk's underlying response.
191-
"""
192-
if len(responses) == 1 and canonical_url is None:
193-
return responses[0]
194-
195-
# Headers come from the response with the lowest reported remaining quota;
196-
# ``_lowest_remaining`` returns the lone response as-is
197-
# for a single-element list). ``_merge_response`` re-sums elapsed onto a
198-
# fresh copy, so repeated calls (e.g. via ``ChunkedCall.partial_response``
199-
# during resume) stay idempotent.
200-
elapsed = sum((_safe_elapsed(r) for r in responses), start=timedelta())
201-
return _merge_response(
202-
responses[0],
203-
headers_from=_lowest_remaining(responses),
204-
elapsed=elapsed,
205-
url=canonical_url,
206-
)
1+
"""Compatibility imports for response aggregation now owned by transport."""
2+
3+
from dataretrieval.transport.combining import (
4+
_QUOTA_HEADER,
5+
_combine_chunk_frames,
6+
_combine_chunk_responses,
7+
_lowest_remaining,
8+
_merge_response,
9+
_safe_elapsed,
10+
_set_response_url,
11+
)
12+
13+
__all__ = [
14+
"_QUOTA_HEADER",
15+
"_combine_chunk_frames",
16+
"_combine_chunk_responses",
17+
"_lowest_remaining",
18+
"_merge_response",
19+
"_safe_elapsed",
20+
"_set_response_url",
21+
]

0 commit comments

Comments
 (0)