Skip to content

Commit 63526fd

Browse files
thodson-usgsclaude
andcommitted
refactor(errors)!: a lean, idiomatic DataRetrievalError taxonomy
Every request failure raises a subclass of DataRetrievalError, so a caller can handle any of them with a single `except dataretrieval.DataRetrievalError`. The taxonomy stays small -- it adds only what the underlying httpx exceptions can't express: DataRetrievalError(Exception) |- HTTPError # .status_code -- the server returned an error status | '- TransientError # .retry_after -- retryable (429 / 5xx) | |- RateLimited # 429 | '- ServiceUnavailable # 5xx |- RequestTooLarge # the request can't fit | |- URLTooLong # 414 / client-side over-long URL | '- Unchunkable # the Water Data chunker can't split the call '- NoDataError # a 200 response with no data One factory -- error_for_status(status, message, *, retry_after) -- maps a status to its type, and every request path routes through it (the legacy `query` path, the Water Data chunker, nldi, nadp, streamstats), so a given status surfaces as the same type everywhere. A fatal 4xx is a generic HTTPError carrying .status_code (inspect the code rather than a class per code). The chunker keys retry/resume on TransientError; connection-level failures (timeouts, DNS) surface as httpx exceptions on the single-shot paths. The typed errors are picklable, so they survive a pickle / deepcopy back from a multiprocessing / lithops worker (a chunk-interruption error sheds its live resume handle to make the trip). A too-long-URL status (413 / 414) on the legacy `query` path keeps the actionable "split your query" remediation message (the same one the client-side over-long-URL case raises), rather than degrading to a bare HTTP-status line. BREAKING CHANGES - Request failures raise typed DataRetrievalError subclasses instead of bare ValueError / RuntimeError / httpx.HTTPStatusError. The exceptions root only at DataRetrievalError(Exception) and no longer also inherit ValueError / RuntimeError -- catch DataRetrievalError (or a subclass), not the builtins. - A fatal 4xx raises HTTPError (read .status_code); there are no per-code types. - The empty-result error is renamed NoSitesError -> NoDataError (it is raised from the shared query path for any module, not just NWIS "sites"). NoSitesError stays as a deprecated alias and will be removed in a future release. Also adds a dataretrieval.exceptions API docs page and a NEWS.md changelog entry. mypy --strict clean; ruff clean; full suite green (487 passed, 2 skipped); the Water Data chunker's resume tests pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ecf2833 commit 63526fd

18 files changed

Lines changed: 464 additions & 263 deletions

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
**06/03/2026:** The request-error hierarchy is now unified. Every module (`nwis`, `wqp`, `nldi`, `waterdata`, `nadp`, `streamstats`) raises a subclass of `dataretrieval.DataRetrievalError` on a failed request, so a single `except dataretrieval.DataRetrievalError` spans them all. An HTTP error status surfaces as an `HTTPError` carrying `.status_code` (inspect it to branch on a specific code); the retryable 429/5xx subset is `TransientError` (`RateLimited` / `ServiceUnavailable`, carrying `.retry_after`); and a request too large to satisfy is a `RequestTooLarge` (`URLTooLong` for an over-long single request, `Unchunkable` when the Water Data chunker cannot split a call small enough). Connection-level failures (timeouts, DNS) still surface as `httpx` exceptions on the single-shot paths. **Breaking change:** these exceptions no longer multiply-inherit a built-in — code that caught request failures with `except ValueError` or `except RuntimeError` should switch to `except dataretrieval.DataRetrievalError` (or a specific subclass). The error raised on a 200-but-empty result, formerly `NoSitesError`, is renamed `NoDataError` (the old name leaked NWIS-era "sites" terminology and the condition is general); `NoSitesError` remains as a deprecated alias and will be removed in a future release.
2+
13
**05/17/2026:** The OGC `waterdata` getters (`get_daily`, `get_continuous`, `get_field_measurements`, and the rest of the multi-value-capable functions) now transparently chunk requests whose URLs would otherwise exceed the server's ~8 KB byte limit.
24

35
**05/16/2026:** Fixed silent truncation in the paginated `waterdata` request loops (`_walk_pages` and `get_stats_data`). Mid-pagination failures (HTTP 429, 5xx, network error) were previously swallowed — pagination would quietly stop and the function would return whatever rows it had collected, leaving callers with truncated DataFrames they had no way to detect. The loops now status-check every page like the initial request and raise `RuntimeError` on any failure, with the upstream exception chained as `__cause__` and a short menu of recovery actions (wait and retry, reduce the request, or obtain an API token) in the message. **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).

dataretrieval/__init__.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
``nldi`` requires geopandas (``pip install dataretrieval[nldi]``) and is
1818
imported on demand: ``from dataretrieval import nldi``.
1919
20-
Every request failure raises a subclass of :class:`dataretrieval.DataRetrievalError`;
21-
the taxonomy lives in ``dataretrieval.exceptions``.
20+
When a request gets an HTTP error response it raises a subclass of
21+
:class:`dataretrieval.DataRetrievalError` (the taxonomy lives in
22+
``dataretrieval.exceptions``). Connection-level failures (timeouts, DNS) still
23+
surface as ``httpx`` exceptions on the single-shot service paths.
2224
"""
2325

2426
from importlib.metadata import PackageNotFoundError, version
@@ -29,10 +31,10 @@
2931
__version__ = "version-unknown"
3032

3133
from dataretrieval.exceptions import (
32-
BadRequestError,
3334
DataRetrievalError,
35+
HTTPError,
36+
NoDataError,
3437
NoSitesError,
35-
NotFoundError,
3638
RateLimited,
3739
RequestTooLarge,
3840
ServiceUnavailable,
@@ -64,10 +66,10 @@
6466
# error taxonomy (canonical home: ``dataretrieval.exceptions``), re-exported
6567
# so callers can ``except dataretrieval.DataRetrievalError``
6668
"exceptions",
67-
"BadRequestError",
6869
"DataRetrievalError",
69-
"NoSitesError",
70-
"NotFoundError",
70+
"HTTPError",
71+
"NoDataError",
72+
"NoSitesError", # deprecated alias for NoDataError
7173
"RateLimited",
7274
"RequestTooLarge",
7375
"ServiceUnavailable",

dataretrieval/exceptions.py

Lines changed: 196 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,202 @@
11
"""Exception taxonomy for ``dataretrieval``.
22
3-
A failed request from any service module (``nwis``, ``wqp``, ``waterdata``,
4-
``nldi``, ...) raises a subclass of :class:`DataRetrievalError`, so a caller can
5-
handle any request failure with a single ``except dataretrieval.DataRetrievalError``.
6-
7-
The tree has two intermediate bases a caller can catch to span a whole family:
8-
:class:`RequestTooLarge` (the request can't fit, however it was issued) and
9-
:class:`TransientError` (a temporary failure worth retrying).
10-
11-
This module deliberately has no third-party dependencies, so any module can
12-
import it without pulling in pandas/httpx.
3+
Every service module (``nwis``, ``wqp``, ``nldi``, ``waterdata``, ``nadp``,
4+
``streamstats``) raises a subclass of :class:`DataRetrievalError` when a request
5+
fails, so one ``except dataretrieval.DataRetrievalError`` catches them all.
6+
Connection-level failures (timeouts, DNS, refused connections) still surface as
7+
``httpx`` exceptions on the single-shot request paths.
8+
9+
Most failures are an :class:`HTTPError` carrying the response ``.status_code``,
10+
of which :class:`TransientError` (429 / 5xx) is the retryable subset. The rest
11+
aren't a plain status: :class:`RequestTooLarge` (with :class:`URLTooLong` /
12+
:class:`Unchunkable`) and :class:`NoDataError`. :func:`error_for_status` is the
13+
one place that maps a status to its type.
14+
15+
This module has no third-party runtime dependencies -- ``httpx`` is imported only
16+
for type checking -- so any module can import it without pulling in pandas / httpx
17+
and without risking an import cycle.
1318
"""
1419

1520
from __future__ import annotations
1621

17-
from typing import TYPE_CHECKING
22+
from typing import TYPE_CHECKING, Any, ClassVar
1823

1924
if TYPE_CHECKING:
2025
import httpx
2126

2227
__all__ = [
2328
"DataRetrievalError",
24-
"BadRequestError",
25-
"NotFoundError",
26-
"RequestTooLarge",
27-
"URLTooLong",
28-
"Unchunkable",
29-
"NoSitesError",
29+
"HTTPError",
3030
"TransientError",
3131
"RateLimited",
3232
"ServiceUnavailable",
33+
"RequestTooLarge",
34+
"URLTooLong",
35+
"Unchunkable",
36+
"NoDataError",
37+
"NoSitesError", # deprecated alias for NoDataError
38+
"error_for_status",
3339
]
3440

3541

3642
class DataRetrievalError(Exception):
37-
"""Base class for errors raised when a request to a USGS or EPA web
38-
service fails.
43+
"""Base class for every failed-request error in ``dataretrieval``.
3944
40-
Every service module (``nwis``, ``wqp``, ``waterdata``, ``nldi``, ...)
41-
raises a subclass of this when a request fails, so a caller can handle any
42-
request failure uniformly::
45+
Catch it to handle any USGS or EPA service failure uniformly::
4346
4447
try:
4548
df, md = dataretrieval.wqp.get_results(...)
4649
except dataretrieval.DataRetrievalError:
4750
...
4851
49-
Subclasses also inherit from the built-in exception this package has
50-
historically raised for the condition's *kind* -- :class:`ValueError` for a
51-
request that can't succeed as written (bad params, too large), and
52-
:class:`RuntimeError` for a transient transport failure -- so existing
53-
``except ValueError`` / ``except RuntimeError`` handlers keep working.
52+
Connection-level failures (timeouts, DNS) still surface as ``httpx``
53+
exceptions on the single-shot request paths.
5454
"""
5555

56+
def __reduce__(self) -> tuple[Any, ...]:
57+
# The status subclasses take keyword-only fields (status_code /
58+
# retry_after) that the default ``BaseException.__reduce__`` can't
59+
# round-trip: it rebuilds via ``cls(*self.args)``, which raises TypeError
60+
# before the saved state is applied. Rebuild from args + state via
61+
# ``__new__`` instead so every subclass survives a pickle / deepcopy back
62+
# from a multiprocessing / lithops worker. A subclass holding
63+
# un-picklable live state (e.g. the chunker's ``ChunkInterrupted.call``)
64+
# overrides ``_pickle_state`` to shed it.
65+
return (_rebuild_error, (self.__class__, self.args, self._pickle_state()))
66+
67+
def _pickle_state(self) -> dict[str, Any]:
68+
"""Instance state to serialize in :meth:`__reduce__`.
69+
70+
Defaults to ``__dict__``; override to drop attributes that can't pickle.
71+
"""
72+
return self.__dict__
73+
74+
75+
def _rebuild_error(
76+
cls: type[DataRetrievalError],
77+
args: tuple[Any, ...],
78+
state: dict[str, Any],
79+
) -> DataRetrievalError:
80+
"""Rebuild a :class:`DataRetrievalError` without calling ``__init__``.
81+
82+
See :meth:`DataRetrievalError.__reduce__`.
83+
"""
84+
err = cls.__new__(cls)
85+
err.args = args
86+
err.__dict__.update(state)
87+
return err
5688

57-
# --- Fatal client errors -------------------------------------------------
58-
# The request can't succeed as written; retrying it unchanged won't help. Each
59-
# is also a ``ValueError`` -- the built-in the legacy ``query`` path has always
60-
# raised -- so existing ``except ValueError`` handlers keep working.
6189

90+
# --- HTTP status errors --------------------------------------------------
6291

63-
class BadRequestError(DataRetrievalError, ValueError):
64-
"""The service rejected the request parameters (HTTP 400)."""
6592

93+
class HTTPError(DataRetrievalError):
94+
"""The service returned an error HTTP status.
6695
67-
class NotFoundError(DataRetrievalError, ValueError):
68-
"""The requested resource was not found; often an empty query (HTTP 404)."""
96+
The numeric status is on :attr:`status_code`; branch on it, e.g.
97+
``except HTTPError as e: ... if e.status_code == 404``. :class:`TransientError`
98+
(429 / 5xx) is the retryable subset, and is itself an ``HTTPError``. The one
99+
exception to "a status is an ``HTTPError``" is a request the service rejects
100+
as too long: it surfaces as :class:`URLTooLong` (a :class:`RequestTooLarge`),
101+
*not* an ``HTTPError`` -- so catch :class:`DataRetrievalError` to be certain
102+
of spanning every failure. See :func:`error_for_status` for the full mapping.
103+
104+
Parameters
105+
----------
106+
message : str
107+
Human-readable error message.
108+
status_code : int
109+
The HTTP status the service returned.
110+
"""
111+
112+
def __init__(self, message: str, *, status_code: int) -> None:
113+
super().__init__(message)
114+
self.status_code = status_code
115+
116+
117+
class TransientError(HTTPError):
118+
"""A 429 or 5xx the server may serve on a later try -- :class:`RateLimited`
119+
for 429, :class:`ServiceUnavailable` for 5xx.
120+
121+
This only classifies the condition; it does not itself retry. Whether to
122+
retry is up to the calling path: a single-shot request raises it for the
123+
caller to handle (e.g. wait :attr:`retry_after` seconds, then re-issue),
124+
while the Water Data chunker retries and resumes automatically.
125+
126+
Parameters
127+
----------
128+
message : str
129+
Human-readable error message.
130+
status_code : int, optional
131+
The HTTP status the service returned. Defaults to the leaf's canonical
132+
code (429 / 503) when omitted; :func:`error_for_status` always passes the
133+
real status.
134+
retry_after : float, optional
135+
Seconds to wait before retrying, parsed from the ``Retry-After`` response
136+
header; ``None`` when the header is absent or unparseable.
137+
"""
138+
139+
#: Canonical status a concrete transient stamps when built without an
140+
#: explicit ``status_code`` (:class:`RateLimited` = 429,
141+
#: :class:`ServiceUnavailable` = 503). ``TransientError`` itself is abstract
142+
#: and sets none, so constructing it bare requires ``status_code``.
143+
_DEFAULT_STATUS: ClassVar[int]
144+
145+
def __init__(
146+
self,
147+
message: str,
148+
*,
149+
status_code: int | None = None,
150+
retry_after: float | None = None,
151+
) -> None:
152+
if status_code is None:
153+
status_code = getattr(self, "_DEFAULT_STATUS", None)
154+
if status_code is None:
155+
raise TypeError(
156+
f"{type(self).__name__} requires status_code "
157+
"(only the RateLimited / ServiceUnavailable leaves default it)"
158+
)
159+
super().__init__(message, status_code=status_code)
160+
self.retry_after = retry_after
161+
162+
163+
class RateLimited(TransientError):
164+
"""A request was rejected with HTTP 429 (too many requests)."""
69165

166+
_DEFAULT_STATUS = 429
70167

71-
class RequestTooLarge(DataRetrievalError, ValueError):
168+
169+
class ServiceUnavailable(TransientError):
170+
"""A request was rejected with a server error (HTTP 5xx).
171+
172+
Raised by both the legacy ``query`` path and the Water Data path, so a 5xx
173+
surfaces as one type whichever subsystem issued the request. ``.status_code``
174+
holds the actual 5xx; it falls back to 503 only on a bare hand-construction.
175+
"""
176+
177+
_DEFAULT_STATUS = 503
178+
179+
180+
# --- Request can't fit (not necessarily an HTTP status) ------------------
181+
182+
183+
class RequestTooLarge(DataRetrievalError):
72184
"""The request is too large for the service to satisfy.
73185
74-
A base for the two ways a request can exceed what the service accepts;
75-
catch it to handle either. The concrete subclasses are :class:`URLTooLong`
76-
(a single request the server rejected) and :class:`Unchunkable` (the Water
77-
Data chunker could not split the call small enough to fit).
186+
Base for the two ways that happens; catch it to handle either:
187+
:class:`URLTooLong` (a single request rejected for length) and
188+
:class:`Unchunkable` (a Water Data call the chunker could not split small
189+
enough to fit).
78190
"""
79191

80192

81193
class URLTooLong(RequestTooLarge):
82-
"""A single request URL exceeded the service's limit (HTTP 414, or rejected
83-
client-side before it was sent).
194+
"""A single request URL was too long for the service.
84195
85-
Raised by the legacy ``query`` path, which issues one request without
86-
chunking. Remediation: query fewer sites, or split the call manually.
196+
Raised on the legacy ``query`` path (which sends one un-chunked request),
197+
whether the URL is rejected client-side before sending or by the server
198+
(see :func:`error_for_status`). Remediation: query fewer sites, or split the
199+
call manually.
87200
"""
88201

89202

@@ -99,56 +212,57 @@ class Unchunkable(RequestTooLarge):
99212
"""
100213

101214

102-
class NoSitesError(DataRetrievalError):
103-
"""The selection criteria matched no sites/data."""
215+
# --- Empty result --------------------------------------------------------
216+
217+
218+
class NoDataError(DataRetrievalError):
219+
"""The request succeeded (HTTP 200) but the selection criteria matched
220+
no data."""
104221

105222
def __init__(self, url: httpx.URL) -> None:
106223
self.url = url
107224

108225
def __str__(self) -> str:
109226
return (
110-
"No sites/data found using the selection criteria specified in "
111-
f"url: {self.url}"
227+
f"No data found using the selection criteria specified in url: {self.url}"
112228
)
113229

114230

115-
# --- Transient transport errors ------------------------------------------
116-
# The service was reachable but temporarily refused the request; the same call
117-
# may succeed if retried. Each is also a ``RuntimeError`` (the built-in the
118-
# waterdata path has always raised). The Water Data chunker recognizes them via
119-
# ``isinstance(exc, TransientError)`` and wraps them as resumable
120-
# ``ChunkInterrupted`` subclasses.
121-
122-
123-
class TransientError(DataRetrievalError, RuntimeError):
124-
"""Base for transient HTTP failures that are worth an automatic retry.
125-
126-
One subclass per recoverable HTTP status family (429 -> :class:`RateLimited`,
127-
5xx -> :class:`ServiceUnavailable`); the Water Data chunker recognizes them
128-
by this shared base and wraps them as resumable interruptions.
129-
130-
Parameters
131-
----------
132-
message : str
133-
Human-readable error message.
134-
retry_after : float, optional
135-
Seconds to wait before retrying, parsed from the ``Retry-After``
136-
response header; stored on the :attr:`retry_after` attribute (``None``
137-
when the header is absent or unparseable).
138-
"""
139-
140-
def __init__(self, message: str, *, retry_after: float | None = None) -> None:
141-
super().__init__(message)
142-
self.retry_after = retry_after
231+
#: Deprecated alias for :class:`NoDataError`. The original name leaked NWIS-era
232+
#: "sites" terminology; it is retained so existing ``except NoSitesError``
233+
#: handlers keep working, and will be removed in a future release.
234+
NoSitesError = NoDataError
143235

144236

145-
class RateLimited(TransientError):
146-
"""A request was rejected with HTTP 429 (too many requests)."""
237+
def error_for_status(
238+
status: int, message: str, *, retry_after: float | None = None
239+
) -> DataRetrievalError:
240+
"""Return the typed :class:`DataRetrievalError` for an HTTP error *status*.
147241
242+
The one status-to-type mapping every request path shares (the legacy
243+
``query`` path, ``waterdata``, ``nadp`` / ``streamstats``), so a given status
244+
becomes the same type everywhere:
148245
149-
class ServiceUnavailable(TransientError):
150-
"""A request was rejected with a server error (HTTP 5xx).
246+
* **413, 414** -> :class:`URLTooLong` (a :class:`RequestTooLarge`) -- the
247+
"too long" semantic is more actionable than a bare status, and it matches
248+
the client-side over-long-URL case
249+
* **429** -> :class:`RateLimited`
250+
* **5xx** -> :class:`ServiceUnavailable`
251+
* **anything else** -> :class:`HTTPError`
151252
152-
Raised by both the legacy ``query`` path and the Water Data path, so a 5xx
153-
surfaces as one type regardless of which subsystem issued the request.
253+
``message`` is used verbatim; ``retry_after`` is attached only to the
254+
transient (:class:`TransientError`) types. *status* must be an error status
255+
(``>= 400``) -- classifying a success or redirect is a usage error and raises
256+
:class:`ValueError`.
154257
"""
258+
if status < 400:
259+
raise ValueError(
260+
f"error_for_status expects an HTTP error status (>= 400), got {status}"
261+
)
262+
if status in (413, 414):
263+
return URLTooLong(message)
264+
if status == 429:
265+
return RateLimited(message, status_code=status, retry_after=retry_after)
266+
if 500 <= status < 600:
267+
return ServiceUnavailable(message, status_code=status, retry_after=retry_after)
268+
return HTTPError(message, status_code=status)

0 commit comments

Comments
 (0)