Skip to content

Commit 6175455

Browse files
committed
fix(ogc): preserve chunking result contracts
Keep feature-ID deduplication across every chunking axis and retain nested GeoJSON property flattening. Centralize transient classification, preserve resumability for future typed transients, and tighten httpx typing and cross-module documentation.
1 parent 8cfac54 commit 6175455

8 files changed

Lines changed: 141 additions & 187 deletions

File tree

dataretrieval/ogc/chunking.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ def _combine_raw(self) -> tuple[pd.DataFrame, httpx.Response]:
454454
frames = [self._chunks[i][0] for i in sorted(self._chunks)]
455455
responses = [response for _, response in self._chunks.values()]
456456
return (
457-
_combine_chunk_frames(frames, dedup=self.plan.has_filter_axis),
457+
_combine_chunk_frames(frames),
458458
_combine_chunk_responses(responses, self.plan.canonical_url),
459459
)
460460

dataretrieval/ogc/combining.py

Lines changed: 45 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -37,37 +37,36 @@ def _safe_elapsed(response: httpx.Response) -> timedelta:
3737
treat the missing attribute as zero elapsed.
3838
"""
3939
try:
40-
return response.elapsed
40+
elapsed: object = response.elapsed
4141
except RuntimeError:
4242
return timedelta(0)
43+
return elapsed if isinstance(elapsed, timedelta) else timedelta(0)
4344

4445

4546
def _set_response_url(response: httpx.Response, url: str | httpx.URL) -> None:
4647
"""
4748
Overwrite the URL surfaced by a response without back-propagating
4849
the change into any aliased original.
4950
50-
Try the direct assignment first: on lightweight test mocks ``.url``
51-
is a plain writable attribute. On real ``httpx.Response`` it's
52-
read-only (it resolves through the bound request), so swap in a
53-
fresh :class:`httpx.Request` carrying the new URL — mutating the
54-
existing one would leak through any shallow copy that shares the
55-
same ``.request``.
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.
5655
"""
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))
5762
try:
58-
response.url = url # type: ignore[misc, assignment]
59-
except AttributeError:
60-
target = httpx.URL(str(url))
61-
try:
62-
old = response.request
63-
except RuntimeError:
64-
# No request bound (some hand-built httpx.Response fixtures);
65-
# synthesize a minimal one to hold the URL.
66-
response.request = httpx.Request("GET", target)
67-
return
68-
response.request = httpx.Request(
69-
method=old.method, url=target, headers=old.headers
70-
)
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)
7170

7271

7372
def _lowest_remaining(responses: list[httpx.Response]) -> httpx.Response:
@@ -104,8 +103,9 @@ def _merge_response(
104103
given. ``base`` and ``headers_from`` are never mutated, and the fresh
105104
``httpx.Headers`` means downstream mutations don't back-propagate into any
106105
underlying response — so callers may re-fold idempotently. This is the one
107-
low-level merge behind both pagination (:func:`_paginate`) and the chunked /
108-
fan-out aggregation (:func:`_combine_chunk_responses`)."""
106+
low-level merge behind both pagination
107+
(:func:`~dataretrieval.ogc.engine._paginate`) and the chunked / fan-out
108+
aggregation (:func:`_combine_chunk_responses`)."""
109109
merged = copy.copy(base)
110110
merged.headers = httpx.Headers(headers_from.headers)
111111
merged.elapsed = elapsed
@@ -114,77 +114,37 @@ def _merge_response(
114114
return merged
115115

116116

117-
def _combine_chunk_frames(
118-
frames: list[pd.DataFrame], *, dedup: bool = True
119-
) -> pd.DataFrame:
120-
"""
121-
Concatenate per-chunk frames, dropping empties and optionally deduping
122-
by ``id``.
117+
def _combine_chunk_frames(frames: list[pd.DataFrame]) -> pd.DataFrame:
118+
"""Concatenate per-chunk frames and deduplicate non-null feature IDs.
123119
124-
Parameters
125-
----------
126-
frames : list[pandas.DataFrame]
127-
One frame per completed sub-request.
128-
dedup : bool, default True
129-
Whether to drop duplicate rows keyed on the ``id`` column.
130-
List-axis chunks (multi-value list parameters like
131-
``monitoring_location_id``) produce non-overlapping partitions,
132-
so dedup is unnecessary and skipping it saves ~2 ms on a 50 k-row
133-
result. Filter-axis chunks *can* overlap (a feature matching
134-
multiple OR-clauses appears in each clause's chunk), so dedup is
135-
required there. Callers that know their chunks don't overlap
136-
(e.g. :meth:`ChunkedCall._combine_raw` when the plan has no
137-
filter axis) pass ``dedup=False``.
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.
138124
139-
Returns
140-
-------
141-
pandas.DataFrame
142-
The concatenated (and optionally deduplicated) result. Empty when
143-
every input frame is empty.
144-
145-
Notes
146-
-----
147-
An empty chunk can be a plain ``pd.DataFrame()`` (no geopandas);
148-
concatenating it with real ``GeoDataFrame``s downgrades the result
149-
to plain ``DataFrame`` and strips geometry/CRS, so empties are
150-
dropped first. Dedup on the pre-rename feature ``id`` keeps
151-
overlapping user OR-clauses from producing duplicate rows across
152-
chunks.
153-
154-
Dedup is restricted to rows whose ``id`` is non-null. ``pandas``
155-
treats NaN==NaN as a duplicate for ``drop_duplicates``, so a
156-
blanket call would collapse every id-less row into a single one —
157-
silent data loss if any chunk emits features without an
158-
``id`` field.
125+
Deduplication is unconditional because overlap is possible on every plan
126+
axis. Filter clauses can match the same feature, and list inputs can contain
127+
repeated values or otherwise select overlapping records. Rows without an
128+
``id`` are preserved verbatim: pandas treats null values as duplicates, so
129+
applying ``drop_duplicates`` to those rows would silently lose data.
159130
"""
160-
non_empty = [f for f in frames if not f.empty]
131+
non_empty = [frame for frame in frames if not frame.empty]
161132
if not non_empty:
162-
# Preserve the frame type (GeoDataFrame vs DataFrame) of the
163-
# input even when every chunk is empty — ``_get_resp_data``
164-
# returns ``gpd.GeoDataFrame()`` on empty geopd responses, and
165-
# returning a plain ``pd.DataFrame()`` here would downgrade
166-
# the type in a downstream ``pd.concat([result, geo_page])`` to
167-
# a plain DataFrame and strip geometry/CRS.
168133
return frames[0] if frames else pd.DataFrame()
169134
if len(non_empty) == 1:
170-
# Single-completed-chunk fast path. Return a copy so callers
171-
# who treat ``ChunkedCall.partial_frame`` as a fresh result
172-
# (the property docstring says "live; recomputed per access")
173-
# don't accidentally mutate ``_chunks[0][0]`` in place.
174135
return non_empty[0].copy()
136+
175137
combined = pd.concat(non_empty, ignore_index=True)
176-
if dedup and "id" in combined.columns:
177-
has_id = combined["id"].notna()
178-
if has_id.all():
179-
combined = combined.drop_duplicates(subset="id", ignore_index=True)
180-
elif has_id.any():
181-
# Mixed: dedupe only the id-bearing rows; preserve id-less
182-
# rows verbatim (their order relative to id-bearing rows
183-
# may shift, which is acceptable — dedup can't be id-keyed
184-
# for rows without an id).
185-
id_rows = combined[has_id].drop_duplicates(subset="id")
186-
no_id_rows = combined[~has_id]
187-
combined = pd.concat([id_rows, no_id_rows], ignore_index=True)
138+
if "id" not in combined.columns:
139+
return combined
140+
141+
has_id = combined["id"].notna()
142+
if has_id.all():
143+
return combined.drop_duplicates(subset="id", ignore_index=True)
144+
if has_id.any():
145+
id_rows = combined[has_id].drop_duplicates(subset="id")
146+
no_id_rows = combined[~has_id]
147+
return pd.concat([id_rows, no_id_rows], ignore_index=True)
188148
return combined
189149

190150

dataretrieval/ogc/planning.py

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
drive it.
1010
1111
Result recombination — reassembling the per-chunk frames and responses
12-
back into one result (:func:`_combine_chunk_frames`,
13-
:func:`_combine_chunk_responses`, etc.) — lives in the sibling
14-
:mod:`dataretrieval.ogc.combining` module, which callers import directly.
12+
back into one result
13+
(:func:`~dataretrieval.ogc.combining._combine_chunk_frames`,
14+
:func:`~dataretrieval.ogc.combining._combine_chunk_responses`, etc.) — lives in
15+
the sibling :mod:`dataretrieval.ogc.combining` module, which callers import
16+
directly.
1517
"""
1618

1719
from __future__ import annotations
@@ -549,28 +551,6 @@ def total(self) -> int:
549551
"""
550552
return math.prod((len(self.chunks[ax.arg_key]) for ax in self.axes), start=1)
551553

552-
@property
553-
def has_filter_axis(self) -> bool:
554-
"""Whether the plan splits along the cql-text ``filter`` axis.
555-
556-
Filter-axis chunks can overlap (a feature matching multiple
557-
OR-clauses appears in each clause's chunk), so deduplication is
558-
required when combining their frames. List-axis chunks (the
559-
common case — multi-value list parameters like
560-
``monitoring_location_id``) never overlap, so the combine step
561-
can skip the ``drop_duplicates`` call.
562-
563-
Returns
564-
-------
565-
bool
566-
``True`` when the plan has a filter axis with >1 chunk;
567-
``False`` otherwise.
568-
"""
569-
return any(
570-
ax.joiner != _LIST_SEP and len(self.chunks[ax.arg_key]) > 1
571-
for ax in self.axes
572-
)
573-
574554
def iter_sub_args(self) -> Iterator[dict[str, Any]]:
575555
"""
576556
Yield substituted args for each sub-request, in deterministic

dataretrieval/ogc/retry.py

Lines changed: 22 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import httpx
2020
import pandas as pd
2121

22-
from dataretrieval.exceptions import RateLimited, ServiceUnavailable, TransientError
22+
from dataretrieval.exceptions import RateLimited, TransientError
2323
from dataretrieval.ogc import progress as _progress
2424
from dataretrieval.ogc.interruptions import (
2525
ChunkInterrupted,
@@ -193,30 +193,19 @@ def backoff(self, attempt: int, retry_after: float | None) -> float:
193193
def _classify_transient(
194194
exc: BaseException,
195195
) -> tuple[type[ChunkInterrupted], float | None] | None:
196-
"""
197-
Classify a SINGLE exception as a known transient (resumable) failure.
198-
199-
Does NOT walk the ``__cause__`` chain — inspects only the exception
200-
passed in. Factored out of :func:`_classify_chunk_error` (which walks
201-
the ``__cause__`` chain and calls this on each link) so the transient
202-
taxonomy lives in one place. :func:`_retryable` deliberately does *not*
203-
delegate here — it uses a narrower set (``httpx.InvalidURL`` and a bare
204-
``httpx.HTTPError`` are resumable but not worth an automatic retry).
205-
206-
Parameters
207-
----------
208-
exc : BaseException
209-
A single exception to classify.
196+
"""Classify one exception as a transient, resumable failure.
210197
211-
Returns
212-
-------
213-
tuple[type[ChunkInterrupted], float or None] or None
214-
``(interrupted_class, retry_after)`` for a recognized transient
215-
failure; ``None`` otherwise.
198+
This function owns the shared exception taxonomy; it deliberately does not
199+
walk ``__cause__``. :func:`_classify_chunk_error` walks wrapped pagination
200+
failures, while :func:`_retryable` applies the narrower automatic-retry
201+
policy to this classification.
216202
"""
217203
if isinstance(exc, RateLimited):
218204
return QuotaExhausted, exc.retry_after
219-
if isinstance(exc, ServiceUnavailable):
205+
if isinstance(exc, TransientError):
206+
# Every non-rate-limit typed transient is a service interruption. This
207+
# fallback keeps future TransientError subclasses resumable after their
208+
# inline retries are exhausted.
220209
return ServiceInterrupted, exc.retry_after
221210
if isinstance(exc, (httpx.HTTPError, httpx.InvalidURL)):
222211
return ServiceInterrupted, None
@@ -272,37 +261,20 @@ def _classify_chunk_error(
272261

273262

274263
def _retryable(exc: BaseException) -> tuple[bool, float | None]:
275-
"""
276-
Decide whether ``exc`` is a transient worth an automatic retry.
277-
278-
Only the *top-level* exception is inspected — unlike
279-
:func:`_classify_chunk_error`, which walks the ``__cause__`` chain.
280-
The distinction matters because ``_paginate`` raises an
281-
initial-request transient (429 / 5xx / :class:`httpx.TransportError`)
282-
*raw*, but wraps a mid-pagination failure as a base ``DataRetrievalError``.
283-
So a raw transient means a sub-request that made no progress and is cheap to
284-
re-issue, whereas a mid-pagination failure is left to escalate to a
285-
resumable :class:`ChunkInterrupted` rather than re-walked from page 1
286-
(which would re-spend the quota just exhausted). ``httpx.InvalidURL``
287-
is never retried — a too-long cursor won't fix on a retry.
264+
"""Decide whether a top-level transient is worth an automatic retry.
288265
289-
Returns
290-
-------
291-
tuple[bool, float or None]
292-
``(retryable, retry_after)`` — the server ``Retry-After`` hint
293-
(seconds) when the transient carried one, else ``None``.
266+
Wrapped mid-pagination failures are not retried from page one; they instead
267+
escalate to a resumable :class:`ChunkInterrupted`. ``httpx.InvalidURL`` and
268+
non-transport ``httpx.HTTPError`` instances are resumable but deterministic,
269+
so they are classified without being retried.
294270
"""
295-
# Only initial-page raw transients (TransientError, TransportError) are
296-
# retryable — not a wrapped mid-pagination DataRetrievalError. This is a
297-
# deliberately narrower taxonomy than _classify_transient's: RateLimited
298-
# and ServiceUnavailable are caught via their TransientError base, but
299-
# httpx.InvalidURL and bare httpx.HTTPError are intentionally excluded
300-
# (a too-long cursor or non-transport HTTP error won't fix on retry), so
301-
# this stays a separate check rather than delegating to _classify_transient.
302-
if isinstance(exc, TransientError):
303-
return True, exc.retry_after
304-
if isinstance(exc, httpx.TransportError):
305-
return True, None
271+
classification = _classify_transient(exc)
272+
if classification is None:
273+
return False, None
274+
275+
_, retry_after = classification
276+
if isinstance(exc, (TransientError, httpx.TransportError)):
277+
return True, retry_after
306278
return False, None
307279

308280

dataretrieval/ogc/shaping.py

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,12 @@ def _get_resp_data(
101101
102102
Notes
103103
-----
104-
The non-geopandas branch builds the frame directly from each
105-
feature's ``properties`` dict, plus the top-level ``id`` and
106-
``geometry.coordinates`` columns — the ``id`` column is always
107-
added (so the downstream rename to the service-specific output id
108-
works even on an all-None id), while the ``geometry`` column is
109-
added only when at least one feature carries geometry. This skips
110-
the GeoJSON envelope entirely, so
111-
newly-added Feature-level fields (e.g. ``geometry.type`` after
112-
USGS migrated to full GeoJSON geometry objects) can't leak into
113-
the result frame; no reactive drop-list needs maintenance every
114-
time the upstream schema grows.
104+
The non-geopandas branch normalizes each feature's ``properties``
105+
object, flattening nested dictionaries with an underscore separator, then
106+
adds the top-level ``id`` and ``geometry.coordinates`` columns. The ``id``
107+
column is always added so the downstream service-specific rename works even
108+
when all IDs are missing; ``geometry`` is added only when coordinates are
109+
present. Feature-level envelope fields are deliberately excluded.
115110
"""
116111
if body is None:
117112
body = resp.json()
@@ -128,17 +123,11 @@ def _get_resp_data(
128123
return _empty_feature_frame(geopd)
129124

130125
if not geopd:
131-
# Build the frame directly from the flat properties dicts rather than
132-
# routing through ``pd.json_normalize``. The OGC API returns flat
133-
# (non-nested) property objects, so ``json_normalize``'s recursive
134-
# flattening is unnecessary overhead (~2.5× slower than plain
135-
# ``pd.DataFrame`` for typical page sizes). The ``id`` key is merged
136-
# into each row dict up-front so the column is always present (may be
137-
# all-None) — ``_arrange_cols`` downstream relies on it for the rename
138-
# to the service-specific output_id (daily_id, channel_measurements_id,
139-
# …).
140-
rows = [{**(f.get("properties") or {}), "id": f.get("id")} for f in features]
141-
df = pd.DataFrame(rows)
126+
properties = [feature.get("properties") or {} for feature in features]
127+
df = pd.json_normalize(properties, sep="_")
128+
# Always materialize the feature-level ID (possibly all-None) so
129+
# ``_arrange_cols`` can perform the documented service-specific rename.
130+
df["id"] = [feature.get("id") for feature in features]
142131
_attach_coordinates(df, features)
143132
return df
144133

dataretrieval/wateruse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ def _next_page_url(response: httpx.Response) -> str | None:
398398
url = response.links.get("next", {}).get("url")
399399
if not url:
400400
return None
401-
return url.replace("https://water.usgs.gov", "https://api.water.usgs.gov", 1)
401+
return str(url).replace("https://water.usgs.gov", "https://api.water.usgs.gov", 1)
402402

403403

404404
def _nwdc_error_detail(response: httpx.Response) -> str | None:

0 commit comments

Comments
 (0)