Skip to content

Commit 3651f23

Browse files
thodson-usgsclaude
andcommitted
fix(errors): consistent error-status gateway + drop NWIS-flavored 413/414 leak
A /code-review (max) of the unified error path surfaced these: - waterdata `_raise_for_non_200` guarded only `status == 200`, so a 2xx-non-200 or 3xx was routed through `error_for_status` and raised as a fatal ClientError on a SUCCESS. Match the legacy `_raise_for_status`: guard `status < 400`. (Latent today -- the OGC API paginates with 200s -- but the two gateways now agree.) - `_raise_for_status` special-cased 413/414 to `_url_too_long_error`, whose message is NWIS-specific ("use fewer sites", an `nwis.get_record()` example). nadp/streamstats (no sites) now route through this helper, so a 413/414 on a streamstats download would emit nonsensical NWIS guidance -- and 413/414 was mapped in two places. Drop the special-case: the factory owns 413/414 -> URLTooLong with a generic message; the NWIS prose stays only on query()'s client-side InvalidURL path (the common too-long-URL trigger). - Status-first message ("HTTP {status} {reason}") so a non-standard status with an empty reason_phrase (520/599 WAF codes) no longer leaves a stray leading space. - Extend `test_retryable_taxonomy` to pin that a typed waterdata 4xx (BadRequestError/ClientError, ValueError-based) is fatal -- never retried -- closing a regression-guard gap (only a bare RuntimeError was covered). - Fix a stale ratings.py comment that called the typed errors "all RuntimeError subclasses" (4xx is now ClientError/BadRequestError; the `except` already catches ValueError, so the skip-bad-feature behavior is intact). ruff + mypy --strict clean; 483 passed / 2 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 426b82f commit 3651f23

5 files changed

Lines changed: 17 additions & 12 deletions

File tree

dataretrieval/utils.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,16 +293,14 @@ def _raise_for_status(response: httpx.Response) -> None:
293293
294294
Shared by the legacy :func:`query` path (and ``nadp`` / ``streamstats``).
295295
Delegates the status-to-type mapping to
296-
:func:`dataretrieval.exceptions.error_for_status`; a 413/414 keeps the richer
297-
"split your query" guidance via :func:`_url_too_long_error`.
296+
:func:`dataretrieval.exceptions.error_for_status`.
298297
"""
299298
status = response.status_code
300299
if status < 400:
301300
return
302-
if status in (413, 414):
303-
raise _url_too_long_error(f"API response reason: {response.reason_phrase}")
304301
raise error_for_status(
305-
status, f"{response.reason_phrase} (HTTP {status}). URL: {response.url}"
302+
status,
303+
f"HTTP {status} {response.reason_phrase}".rstrip() + f" (URL: {response.url})",
306304
)
307305

308306

dataretrieval/waterdata/ratings.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,11 @@ def get_ratings(
185185
try:
186186
out[fid] = _download_and_parse(feature, file_path, ssl_check)
187187
# _download_and_parse can raise the module's typed errors via
188-
# _raise_for_non_200 (RateLimited / ServiceUnavailable / RuntimeError —
189-
# all RuntimeError subclasses), and a feature missing its data asset
190-
# raises LookupError. Catch those too so one bad feature is logged and
191-
# skipped rather than aborting the whole multi-site batch.
188+
# _raise_for_non_200 (RateLimited / ServiceUnavailable, RuntimeError
189+
# subclasses; or BadRequestError / ClientError on a 4xx, ValueError
190+
# subclasses), and a feature missing its data asset raises LookupError.
191+
# Catch those too so one bad feature is logged and skipped rather than
192+
# aborting the whole multi-site batch.
192193
except (
193194
httpx.HTTPError,
194195
RuntimeError,

dataretrieval/waterdata/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ def _raise_for_non_200(resp: httpx.Response) -> None:
551551
``TransientError``) the chunker won't resume.
552552
"""
553553
status = resp.status_code
554-
if status == 200:
554+
if status < 400:
555555
return
556556
raise error_for_status(
557557
status,

tests/utils_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class Test_error_taxonomy:
5757
(400, "BadRequestError", "400", ValueError),
5858
(403, "ClientError", "403", ValueError),
5959
(404, "NotFoundError", "404", ValueError),
60-
(414, "URLTooLong", "Request URL too long", ValueError),
60+
(414, "URLTooLong", "414", ValueError),
6161
(429, "RateLimited", "429", RuntimeError),
6262
(503, "ServiceUnavailable", "503", RuntimeError),
6363
],

tests/waterdata_chunking_test.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1603,13 +1603,19 @@ def test_retry_policy_from_env_honors_monkeypatched_constants(monkeypatch):
16031603

16041604

16051605
def test_retryable_taxonomy():
1606+
from dataretrieval.exceptions import BadRequestError, ClientError
1607+
16061608
assert _retryable(RateLimited("429", retry_after=5.0)) == (True, 5.0)
16071609
assert _retryable(ServiceUnavailable("503")) == (True, None)
16081610
assert _retryable(httpx.ReadTimeout("slow")) == (True, None)
16091611
assert _retryable(httpx.ConnectError("down")) == (True, None)
16101612
# InvalidURL is resumable but NOT retryable (a too-long cursor won't fix).
16111613
assert _retryable(httpx.InvalidURL("too long")) == (False, None)
1612-
# Plain non-transient (e.g. a 4xx programmer error wrapped as RuntimeError).
1614+
# A typed waterdata 4xx is fatal: ValueError-based, not a TransientError, so
1615+
# the chunker must never retry it (BadRequestError/ClientError, plus the old
1616+
# bare RuntimeError for good measure).
1617+
assert _retryable(BadRequestError("400")) == (False, None)
1618+
assert _retryable(ClientError("403")) == (False, None)
16131619
assert _retryable(RuntimeError("400")) == (False, None)
16141620

16151621

0 commit comments

Comments
 (0)