Skip to content

Commit 83f1389

Browse files
thodson-usgsclaude
andcommitted
Raise on any mid-pagination failure instead of silently truncating
`_walk_pages` and `get_stats_data`'s pagination loops have, since PR DOI-USGS#273, logged failures correctly but preserved a "best effort" contract of returning whatever pages had been collected when a follow-up page failed. The waterdata API exposes no resume cursor once a paginated walk is interrupted, so the partial DataFrame couldn't reliably be extended — silently returning it handed callers truncated data they had no way to know was truncated. Both loops now wrap any mid-pagination exception (429, 5xx, network error) in a ``RuntimeError`` carrying: - the number of pages successfully collected, - the upstream cause (as both the message text and ``__cause__`` for programmatic inspection), - a short menu of recovery actions (wait and retry, reduce request size, or obtain an API token). The shared helper ``_paginated_failure_message`` builds the user- facing string so both loops stay aligned. Behavior change: callers that previously consumed partial DataFrames on transient upstream blips will now see an exception they need to handle (typically: retry, possibly with a smaller ``limit`` or narrower query). Called out in NEWS. Tests: - Replaced the prior best-effort-preserves-partial assertions with raises-with-cause-chain assertions for all three failure modes (connection error, 5xx, 429), in both ``_walk_pages`` and ``get_stats_data`` variants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 36866a0 commit 83f1389

3 files changed

Lines changed: 81 additions & 48 deletions

File tree

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
**05/16/2026:** Paginated `waterdata` request loops (`_walk_pages` and `get_stats_data`) now raise on *any* mid-pagination failure (HTTP 429, 5xx, network error, …) instead of logging a warning and returning the rows accumulated up to the failure. The API exposes no resume cursor once a paginated walk is interrupted, so the prior best-effort return-partial behavior silently handed callers truncated DataFrames they couldn't reliably extend — a data-correctness footgun. The wrapper `RuntimeError` carries the upstream cause via `__cause__` plus a short menu of recovery actions (wait and retry, reduce the request, or obtain an API token). **Behavior change**: callers that previously consumed partial DataFrames on transient upstream blips will now see an exception; retry the call (possibly with a smaller `limit` or narrower query).
2+
13
**05/14/2026:** Fixed two latent bugs in the paginated `waterdata` request loop (`_walk_pages` and `get_stats_data`). Previously, when `requests.Session.request(...)` itself raised mid-pagination (network error, timeout), the except block called `_error_body()` on the *prior page's* response, so the logged "error" described the wrong request and could itself crash on non-JSON bodies. Separately, no status-code check was performed on subsequent paginated responses, so a 5xx body that didn't include `numberReturned` was silently treated as an empty page — pagination quietly stopped and the user got truncated data with no error logged. The loop now status-checks each page like the initial request and reports the actual exception. The "best-effort" behavior (return whatever pages were collected) is preserved.
24

35
**05/07/2026:** Bumped the declared minimum Python version from **3.8** to **3.9** (`pyproject.toml`'s `requires-python` and the ruff target). This brings the manifest in line with what was already being tested — CI's matrix has long covered only 3.9, 3.13, and 3.14, the `waterdata` test module already skipped itself on Python < 3.10, and several modules already use 3.9-only stdlib (e.g. `zoneinfo`). Users on 3.8 will no longer be able to install the package; please upgrade.

dataretrieval/waterdata/utils.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,21 @@ def _raise_for_non_200(resp: requests.Response) -> None:
422422
raise RuntimeError(_error_body(resp))
423423

424424

425+
def _paginated_failure_message(pages_collected: int, cause: BaseException) -> str:
426+
"""User-facing message for a mid-pagination failure.
427+
428+
The API exposes no resume cursor, so the caller's only recovery is
429+
to retry the whole call — the message lists the practical knobs.
430+
"""
431+
return (
432+
f"Paginated request failed after collecting {pages_collected} "
433+
f"page(s): {str(cause).rstrip('.')}. To recover: wait for the "
434+
f"rate-limit window to reset and retry, reduce the request size "
435+
f"(e.g. fewer locations, a shorter time range, or a smaller "
436+
f"``limit``), or obtain an API token."
437+
)
438+
439+
425440
def _construct_api_requests(
426441
service: str,
427442
properties: list[str] | None = None,
@@ -647,8 +662,11 @@ def _walk_pages(
647662
648663
Raises
649664
------
650-
Exception
651-
If a request fails/returns a non-200 status code.
665+
RuntimeError
666+
On any mid-pagination failure (non-200 page response or any
667+
exception from ``client.request``). The original exception is
668+
chained as ``__cause__``; the message is built by
669+
``_paginated_failure_message``.
652670
"""
653671
logger.info("Requesting: %s", req.url)
654672

@@ -690,11 +708,10 @@ def _walk_pages(
690708
dfs.append(_get_resp_data(resp, geopd=geopd))
691709
curr_url = _next_req_url(resp)
692710
except Exception as e: # noqa: BLE001
693-
logger.error("Request incomplete: %s", e)
694711
logger.warning(
695712
"Request failed for URL: %s. Data download interrupted.", curr_url
696713
)
697-
curr_url = None
714+
raise RuntimeError(_paginated_failure_message(len(dfs), e)) from e
698715

699716
# Concatenate all pages at once for efficiency
700717
return pd.concat(dfs, ignore_index=True), initial_response
@@ -1154,14 +1171,13 @@ def get_stats_data(
11541171
all_dfs.append(_handle_stats_nesting(body, geopd=GEOPANDAS))
11551172
next_token = body["next"]
11561173
except Exception as e: # noqa: BLE001
1157-
logger.error("Request incomplete: %s", e)
11581174
logger.warning(
11591175
"Request failed for URL: %s (next_token=%s). "
11601176
"Data download interrupted.",
11611177
url,
11621178
next_token,
11631179
)
1164-
next_token = None
1180+
raise RuntimeError(_paginated_failure_message(len(all_dfs), e)) from e
11651181

11661182
dfs = pd.concat(all_dfs, ignore_index=True) if len(all_dfs) > 1 else all_dfs[0]
11671183

tests/waterdata_utils_test.py

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import mock
33

44
import pandas as pd
5+
import pytest
56
import requests
67

78
import dataretrieval.waterdata.utils as _utils_module
@@ -106,12 +107,6 @@ def _resp_ok(features):
106107
return resp
107108

108109

109-
def _error_log_messages(caplog):
110-
"""Pull ERROR-and-above message strings out of caplog. Shared by the
111-
pagination-failure tests below."""
112-
return [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR]
113-
114-
115110
def _walk_pages_with_failure(failure_resp_or_exc):
116111
"""Run _walk_pages where page 1 succeeds and page 2 fails as given."""
117112
resp1 = _resp_ok([{"id": "1", "properties": {"val": "a"}}])
@@ -131,23 +126,21 @@ def _walk_pages_with_failure(failure_resp_or_exc):
131126
return _walk_pages(geopd=False, req=mock_req, client=mock_client)
132127

133128

134-
def test_walk_pages_logs_actual_exception_when_request_raises(caplog):
135-
"""Exception from client.request() must be logged with its actual message."""
136-
caplog.set_level(logging.ERROR, logger=_LOGGER_NAME)
137-
138-
df, _ = _walk_pages_with_failure(requests.ConnectionError("boom"))
139-
140-
# First page's data is preserved (best-effort behavior).
141-
assert list(df["val"]) == ["a"]
142-
# Logged error mentions the actual ConnectionError, not a stale page body.
143-
messages = _error_log_messages(caplog)
144-
assert any("boom" in m for m in messages), messages
129+
def test_walk_pages_raises_on_connection_error_mid_pagination():
130+
"""A connection error mid-pagination must raise with the upstream cause
131+
chained, and the wrapper message must include recovery guidance."""
132+
with pytest.raises(RuntimeError, match="Paginated request failed") as excinfo:
133+
_walk_pages_with_failure(requests.ConnectionError("boom"))
145134

135+
assert isinstance(excinfo.value.__cause__, requests.ConnectionError)
136+
assert "boom" in str(excinfo.value)
137+
assert "API token" in str(excinfo.value)
146138

147-
def test_walk_pages_surfaces_5xx_mid_pagination(caplog):
148-
"""A non-200 mid-pagination response must be logged, not silently swallowed."""
149-
caplog.set_level(logging.ERROR, logger=_LOGGER_NAME)
150139

140+
def test_walk_pages_raises_on_5xx_mid_pagination():
141+
"""A 5xx mid-pagination must raise — partial data is no longer returned
142+
because the API has no resume cursor, so silently truncating is the
143+
wrong default."""
151144
page2_503 = mock.MagicMock()
152145
page2_503.status_code = 503
153146
page2_503.json.return_value = {
@@ -156,11 +149,23 @@ def test_walk_pages_surfaces_5xx_mid_pagination(caplog):
156149
}
157150
page2_503.url = "https://example.com/page2"
158151

159-
df, _ = _walk_pages_with_failure(page2_503)
152+
with pytest.raises(RuntimeError, match="Paginated request failed") as excinfo:
153+
_walk_pages_with_failure(page2_503)
154+
155+
assert "503" in str(excinfo.value) or "ServiceUnavailable" in str(excinfo.value)
156+
157+
158+
def test_walk_pages_raises_on_mid_pagination_429():
159+
"""A 429 mid-pagination must raise. Specific status code is preserved in
160+
the chained cause so callers can branch on rate-limit vs other failures."""
161+
page2_429 = mock.MagicMock()
162+
page2_429.status_code = 429
163+
page2_429.url = "https://example.com/page2"
164+
165+
with pytest.raises(RuntimeError, match="Paginated request failed") as excinfo:
166+
_walk_pages_with_failure(page2_429)
160167

161-
assert list(df["val"]) == ["a"]
162-
messages = _error_log_messages(caplog)
163-
assert any("503" in m or "ServiceUnavailable" in m for m in messages), messages
168+
assert "429" in str(excinfo.value)
164169

165170

166171
def _stats_initial_ok():
@@ -205,23 +210,20 @@ def _run_get_stats_data_with_failure(failure_resp_or_exc, monkeypatch):
205210
)
206211

207212

208-
def test_get_stats_data_logs_actual_exception_when_request_raises(caplog, monkeypatch):
209-
"""get_stats_data variant of the connection-error scenario."""
210-
caplog.set_level(logging.ERROR, logger=_LOGGER_NAME)
213+
def test_get_stats_data_raises_on_connection_error_mid_pagination(monkeypatch):
214+
"""get_stats_data variant of the connection-error-raises contract."""
215+
with pytest.raises(RuntimeError, match="Paginated request failed") as excinfo:
216+
_run_get_stats_data_with_failure(
217+
requests.ConnectionError("stats-boom"),
218+
monkeypatch,
219+
)
211220

212-
_run_get_stats_data_with_failure(
213-
requests.ConnectionError("stats-boom"),
214-
monkeypatch,
215-
)
216-
217-
messages = _error_log_messages(caplog)
218-
assert any("stats-boom" in m for m in messages), messages
221+
assert isinstance(excinfo.value.__cause__, requests.ConnectionError)
222+
assert "stats-boom" in str(excinfo.value)
219223

220224

221-
def test_get_stats_data_surfaces_5xx_mid_pagination(caplog, monkeypatch):
222-
"""get_stats_data variant of the mid-pagination 5xx scenario."""
223-
caplog.set_level(logging.ERROR, logger=_LOGGER_NAME)
224-
225+
def test_get_stats_data_raises_on_5xx_mid_pagination(monkeypatch):
226+
"""get_stats_data variant of the 5xx-raises contract."""
225227
page2_503 = mock.MagicMock()
226228
page2_503.status_code = 503
227229
page2_503.json.return_value = {
@@ -230,10 +232,22 @@ def test_get_stats_data_surfaces_5xx_mid_pagination(caplog, monkeypatch):
230232
}
231233
page2_503.url = "https://example.com/stats?service=foo&next_token=tok2"
232234

233-
_run_get_stats_data_with_failure(page2_503, monkeypatch)
235+
with pytest.raises(RuntimeError, match="Paginated request failed") as excinfo:
236+
_run_get_stats_data_with_failure(page2_503, monkeypatch)
237+
238+
assert "503" in str(excinfo.value) or "ServiceUnavailable" in str(excinfo.value)
239+
240+
241+
def test_get_stats_data_raises_on_mid_pagination_429(monkeypatch):
242+
"""get_stats_data variant of the 429-raises contract."""
243+
page2_429 = mock.MagicMock()
244+
page2_429.status_code = 429
245+
page2_429.url = "https://example.com/stats?service=foo&next_token=tok2"
246+
247+
with pytest.raises(RuntimeError, match="Paginated request failed") as excinfo:
248+
_run_get_stats_data_with_failure(page2_429, monkeypatch)
234249

235-
messages = _error_log_messages(caplog)
236-
assert any("503" in m or "ServiceUnavailable" in m for m in messages), messages
250+
assert "429" in str(excinfo.value)
237251

238252

239253
def test_get_stats_data_warning_includes_next_token(caplog, monkeypatch):
@@ -249,7 +263,8 @@ def test_get_stats_data_warning_includes_next_token(caplog, monkeypatch):
249263
"description": "upstream timeout",
250264
}
251265

252-
_run_get_stats_data_with_failure(page2_503, monkeypatch)
266+
with pytest.raises(RuntimeError):
267+
_run_get_stats_data_with_failure(page2_503, monkeypatch)
253268

254269
warnings_ = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
255270
# The initial response from _stats_initial_ok carries next=tok2.

0 commit comments

Comments
 (0)