Skip to content

Commit 8b14f6c

Browse files
committed
docs(waterdata): Move resume retry-loop example to user guide
The example doesn't belong in get_daily's docstring — it's a topical explanation of an API contract that applies to every chunked getter, not a usage demo of one function. Move it to a dedicated Sphinx user- guide page (waterdata_chunking.rst) covering the chunker's resume contract, the canonical retry-loop pattern with a one-hour deadline, the four resume-validation failure modes, and how to inspect the chunk manifest on successful calls. multi_value_chunked's module docstring and the per-getter resume_from parameter doc now cross-reference the new page.
1 parent 64d1a6e commit 8b14f6c

4 files changed

Lines changed: 162 additions & 59 deletions

File tree

dataretrieval/waterdata/api.py

Lines changed: 22 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ def get_daily(
195195
``QuotaExhausted``) exception from a previous call. The chunker
196196
consults its ``chunk_manifest`` to skip already-completed
197197
sub-requests and fetch only the remainder. Pass the same other
198-
kwargs as the original call. See ``get_daily`` for a worked
198+
kwargs as the original call. See the
199+
:ref:`waterdata-chunking-resume` user guide for a worked
199200
retry-loop example.
200201
201202
Returns
@@ -253,51 +254,6 @@ def get_daily(
253254
... parameter_code="00060",
254255
... time="P7D",
255256
... )
256-
257-
>>> # Resume loop: a heavy chunked query may exhaust the hourly
258-
>>> # rate-limit budget partway through or hit a transient upstream
259-
>>> # error. Catch ``PartialResult``, accumulate the partial frames,
260-
>>> # and re-call with ``resume_from=`` to fetch only the
261-
>>> # outstanding chunks. The USGS API rate-limit window is one
262-
>>> # hour, so a total retry window of one hour is a sensible
263-
>>> # ceiling — anything longer means the failure is structural,
264-
>>> # not transient, and the loop should surface the error.
265-
>>> import time
266-
>>> import pandas as pd
267-
>>> from dataretrieval import waterdata
268-
>>> from dataretrieval.waterdata.chunking import PartialResult
269-
>>>
270-
>>> sites = sites_df["monitoring_location_id"].tolist()
271-
>>> deadline = time.monotonic() + 3600 # one hour cap
272-
>>> partials = []
273-
>>> md = None # carries the latest chunk_manifest between attempts
274-
>>> attempt = 0
275-
>>> while True:
276-
... try:
277-
... df, md = waterdata.get_daily(
278-
... monitoring_location_id=sites,
279-
... parameter_code="00060",
280-
... time="P7D",
281-
... resume_from=md, # ``None`` on the first attempt
282-
... )
283-
... break # full result fetched
284-
... except PartialResult as exc:
285-
... partials.append(exc.partial_frame)
286-
... md = exc.partial_metadata
287-
... if time.monotonic() >= deadline:
288-
... raise TimeoutError(
289-
... f"Could not complete chunked query within one hour "
290-
... f"({md.chunk_manifest.completed}/"
291-
... f"{md.chunk_manifest.total} chunks done)."
292-
... ) from exc
293-
... attempt += 1
294-
... # Exponential backoff, capped at 10 minutes. Quota-
295-
... # reset failures benefit from a longer wait; transient
296-
... # transport errors clear quickly. ``min(...)`` ensures
297-
... # a tight cap; the outer deadline ensures we never wait
298-
... # past one hour total.
299-
... time.sleep(min(60 * 2 ** (attempt - 1), 600))
300-
>>> full = pd.concat([*partials, df], ignore_index=True)
301257
"""
302258
service = "daily"
303259
output_id = "daily_id"
@@ -458,7 +414,8 @@ def get_continuous(
458414
``QuotaExhausted``) exception from a previous call. The chunker
459415
consults its ``chunk_manifest`` to skip already-completed
460416
sub-requests and fetch only the remainder. Pass the same other
461-
kwargs as the original call. See ``get_daily`` for a worked
417+
kwargs as the original call. See the
418+
:ref:`waterdata-chunking-resume` user guide for a worked
462419
retry-loop example.
463420
464421
Returns
@@ -774,7 +731,8 @@ def get_monitoring_locations(
774731
``QuotaExhausted``) exception from a previous call. The chunker
775732
consults its ``chunk_manifest`` to skip already-completed
776733
sub-requests and fetch only the remainder. Pass the same other
777-
kwargs as the original call. See ``get_daily`` for a worked
734+
kwargs as the original call. See the
735+
:ref:`waterdata-chunking-resume` user guide for a worked
778736
retry-loop example.
779737
780738
Returns
@@ -1005,7 +963,8 @@ def get_time_series_metadata(
1005963
``QuotaExhausted``) exception from a previous call. The chunker
1006964
consults its ``chunk_manifest`` to skip already-completed
1007965
sub-requests and fetch only the remainder. Pass the same other
1008-
kwargs as the original call. See ``get_daily`` for a worked
966+
kwargs as the original call. See the
967+
:ref:`waterdata-chunking-resume` user guide for a worked
1009968
retry-loop example.
1010969
1011970
Returns
@@ -1210,7 +1169,8 @@ def get_combined_metadata(
12101169
``QuotaExhausted``) exception from a previous call. The chunker
12111170
consults its ``chunk_manifest`` to skip already-completed
12121171
sub-requests and fetch only the remainder. Pass the same other
1213-
kwargs as the original call. See ``get_daily`` for a worked
1172+
kwargs as the original call. See the
1173+
:ref:`waterdata-chunking-resume` user guide for a worked
12141174
retry-loop example.
12151175
12161176
Returns
@@ -1435,7 +1395,8 @@ def get_latest_continuous(
14351395
``QuotaExhausted``) exception from a previous call. The chunker
14361396
consults its ``chunk_manifest`` to skip already-completed
14371397
sub-requests and fetch only the remainder. Pass the same other
1438-
kwargs as the original call. See ``get_daily`` for a worked
1398+
kwargs as the original call. See the
1399+
:ref:`waterdata-chunking-resume` user guide for a worked
14391400
retry-loop example.
14401401
14411402
Returns
@@ -1640,7 +1601,8 @@ def get_latest_daily(
16401601
``QuotaExhausted``) exception from a previous call. The chunker
16411602
consults its ``chunk_manifest`` to skip already-completed
16421603
sub-requests and fetch only the remainder. Pass the same other
1643-
kwargs as the original call. See ``get_daily`` for a worked
1604+
kwargs as the original call. See the
1605+
:ref:`waterdata-chunking-resume` user guide for a worked
16441606
retry-loop example.
16451607
16461608
Returns
@@ -1836,7 +1798,8 @@ def get_field_measurements(
18361798
``QuotaExhausted``) exception from a previous call. The chunker
18371799
consults its ``chunk_manifest`` to skip already-completed
18381800
sub-requests and fetch only the remainder. Pass the same other
1839-
kwargs as the original call. See ``get_daily`` for a worked
1801+
kwargs as the original call. See the
1802+
:ref:`waterdata-chunking-resume` user guide for a worked
18401803
retry-loop example.
18411804
18421805
Returns
@@ -1962,7 +1925,8 @@ def get_field_measurements_metadata(
19621925
``QuotaExhausted``) exception from a previous call. The chunker
19631926
consults its ``chunk_manifest`` to skip already-completed
19641927
sub-requests and fetch only the remainder. Pass the same other
1965-
kwargs as the original call. See ``get_daily`` for a worked
1928+
kwargs as the original call. See the
1929+
:ref:`waterdata-chunking-resume` user guide for a worked
19661930
retry-loop example.
19671931
19681932
Returns
@@ -2094,7 +2058,8 @@ def get_peaks(
20942058
``QuotaExhausted``) exception from a previous call. The chunker
20952059
consults its ``chunk_manifest`` to skip already-completed
20962060
sub-requests and fetch only the remainder. Pass the same other
2097-
kwargs as the original call. See ``get_daily`` for a worked
2061+
kwargs as the original call. See the
2062+
:ref:`waterdata-chunking-resume` user guide for a worked
20982063
retry-loop example.
20992064
21002065
Returns
@@ -2954,7 +2919,8 @@ def get_channel(
29542919
``QuotaExhausted``) exception from a previous call. The chunker
29552920
consults its ``chunk_manifest`` to skip already-completed
29562921
sub-requests and fetch only the remainder. Pass the same other
2957-
kwargs as the original call. See ``get_daily`` for a worked
2922+
kwargs as the original call. See the
2923+
:ref:`waterdata-chunking-resume` user guide for a worked
29582924
retry-loop example.
29592925
29602926
Returns

dataretrieval/waterdata/chunking.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -617,9 +617,10 @@ def multi_value_chunked(
617617
the caller's kwargs. The wrapper pops it before planning (so it
618618
never reaches the underlying HTTP request), validates the saved
619619
plan matches the fresh plan, and skips the already-completed
620-
cartesian-product combinations. See ``get_daily``'s docstring for
621-
a worked retry-loop example using a one-hour deadline matched to
622-
the API's rate-limit window.
620+
cartesian-product combinations. See the
621+
``waterdata-chunking-resume`` user-guide page for a worked
622+
retry-loop example using a one-hour deadline matched to the
623+
API's rate-limit window.
623624
"""
624625

625626
def decorator(fetch_once: _FetchOnce) -> _FetchOnce:

docs/source/userguide/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ Contents
1515

1616
timeconventions
1717
dataportals
18+
waterdata_chunking
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
.. _waterdata-chunking-resume:
2+
3+
Chunked Queries and Resuming After Failure
4+
------------------------------------------
5+
6+
The OGC ``waterdata`` getters (``get_daily``, ``get_continuous``,
7+
``get_field_measurements``, and the other multi-value-capable
8+
functions) transparently split requests whose URLs would otherwise
9+
exceed the USGS Water Data API's ~8 KB byte limit. A heavy chained
10+
query — e.g. *"pull every stream site in Ohio, then their daily
11+
discharge for the last week"* — fans out into many sub-requests under
12+
the hood and returns one combined DataFrame.
13+
14+
Long-running chunked calls can fail partway through. Two common
15+
causes:
16+
17+
- **Quota exhaustion.** The API rate-limits each HTTP request
18+
(including pagination). The chunker monitors the
19+
``x-ratelimit-remaining`` header between sub-requests and aborts
20+
before issuing the next one if the budget drops below the safety
21+
floor.
22+
- **Transient upstream errors.** A single sub-request can hit a 5xx,
23+
a network blip, or a mid-pagination 429.
24+
25+
Both cases raise :class:`~dataretrieval.waterdata.chunking.PartialResult`
26+
(the quota case raises its subclass
27+
:class:`~dataretrieval.waterdata.chunking.QuotaExhausted`). The
28+
exception carries the combined partial DataFrame and a
29+
:class:`~dataretrieval.waterdata.chunking.ChunkManifest` that records
30+
how many sub-requests of the cartesian-product plan completed.
31+
32+
The same getter accepts the partial metadata back via a
33+
``resume_from=`` kwarg. The chunker validates that the freshly-planned
34+
chunk layout matches the saved manifest, then issues only the
35+
outstanding sub-requests.
36+
37+
The Resume Pattern
38+
******************
39+
40+
The canonical idiom: a loop that retries on ``PartialResult``,
41+
accumulates each call's partial DataFrame, and threads the latest
42+
metadata back into the next attempt as ``resume_from=``. The USGS API
43+
rate-limit window is one hour, so a total retry deadline of one hour
44+
is a sensible ceiling — anything longer means the failure is
45+
structural, not transient, and the loop should surface the error
46+
rather than spin forever.
47+
48+
.. code:: python
49+
50+
import time
51+
import pandas as pd
52+
from dataretrieval import waterdata
53+
from dataretrieval.waterdata.chunking import PartialResult
54+
55+
sites_df, _ = waterdata.get_monitoring_locations(
56+
state_name="Ohio",
57+
site_type="Stream",
58+
)
59+
sites = sites_df["monitoring_location_id"].tolist()
60+
61+
deadline = time.monotonic() + 3600 # one-hour cap
62+
partials = []
63+
md = None # carries the latest chunk_manifest between attempts
64+
attempt = 0
65+
66+
while True:
67+
try:
68+
df, md = waterdata.get_daily(
69+
monitoring_location_id=sites,
70+
parameter_code="00060",
71+
time="P7D",
72+
resume_from=md, # None on the first attempt
73+
)
74+
break # full result fetched
75+
except PartialResult as exc:
76+
partials.append(exc.partial_frame)
77+
md = exc.partial_metadata
78+
if time.monotonic() >= deadline:
79+
raise TimeoutError(
80+
f"Could not complete chunked query within one hour "
81+
f"({md.chunk_manifest.completed}/"
82+
f"{md.chunk_manifest.total} chunks done)."
83+
) from exc
84+
attempt += 1
85+
# Exponential backoff capped at 10 minutes. Quota-reset
86+
# failures benefit from a longer wait; transient transport
87+
# errors clear quickly. The outer deadline still bounds total
88+
# wait time at one hour.
89+
time.sleep(min(60 * 2 ** (attempt - 1), 600))
90+
91+
# Each partial frame plus the final ``df`` is disjoint, so a single
92+
# ``concat`` reconstructs what a successful one-shot call would have
93+
# returned.
94+
full = pd.concat([*partials, df], ignore_index=True)
95+
96+
How Resume Validates the Plan
97+
*****************************
98+
99+
``ChunkManifest`` pins the *normalized cartesian-product plan*, not
100+
just the input kwargs. If a caller changes their inputs between the
101+
original failure and the retry — even in ways that look equivalent —
102+
the freshly-computed plan would differ from the saved one, and
103+
silently re-fetching would interleave data from two incompatible
104+
queries. The chunker raises ``ValueError`` instead, with one of four
105+
explicit messages:
106+
107+
- ``"resume_from has no chunk_manifest"`` — the metadata is from a
108+
call that wasn't chunked (or from a different source entirely).
109+
- ``"do not produce a chunk plan"`` — the current args fit in one
110+
round-trip, so there is no plan to skip against.
111+
- ``"manifest does not match the current chunk plan"`` — the input
112+
list changed between calls.
113+
- ``"already complete"`` — the saved manifest is fully consumed;
114+
drop ``resume_from``.
115+
116+
Inspecting the Manifest on Success
117+
**********************************
118+
119+
The manifest is also attached to ``BaseMetadata.chunk_manifest`` on
120+
successful chunked calls, so callers can log fan-out information
121+
without catching anything:
122+
123+
.. code:: python
124+
125+
df, md = waterdata.get_daily(
126+
monitoring_location_id=sites,
127+
parameter_code="00060",
128+
time="P7D",
129+
)
130+
if md.chunk_manifest is not None:
131+
m = md.chunk_manifest
132+
logger.info("query fanned out across %d sub-requests", m.total)
133+
134+
For calls that did not need chunking, ``md.chunk_manifest`` is
135+
``None``.

0 commit comments

Comments
 (0)