Skip to content

Commit 2785ebe

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 propagate cleanly back from multiprocessing / lithops workers. 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 (485 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 2785ebe

18 files changed

Lines changed: 380 additions & 259 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: 168 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,176 @@
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+
When a request gets an HTTP error response, the service modules (``nwis``,
4+
``wqp``, ``nldi``, ``waterdata``, ``nadp``, ``streamstats``) raise a subclass of
5+
:class:`DataRetrievalError`, so a caller can handle any of them with one
6+
``except dataretrieval.DataRetrievalError``. Connection-level failures (timeouts,
7+
DNS, refused connections) surface as ``httpx`` exceptions on the single-shot
8+
request paths.
9+
10+
A status error is an :class:`HTTPError` carrying ``.status_code`` (inspect it to
11+
branch on the specific code); :class:`TransientError` is the retryable subset
12+
(429 / 5xx). A few failures are not a plain status -- :class:`RequestTooLarge`
13+
(:class:`URLTooLong` / :class:`Unchunkable`) and :class:`NoDataError`.
14+
15+
This module imports only ``httpx`` (the package's core HTTP dependency, always
16+
installed) -- not pandas/geopandas -- so it stays cheap to import and free of
17+
import cycles.
1318
"""
1419

1520
from __future__ import annotations
1621

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

19-
if TYPE_CHECKING:
20-
import httpx
24+
import httpx
2125

2226
__all__ = [
2327
"DataRetrievalError",
24-
"BadRequestError",
25-
"NotFoundError",
26-
"RequestTooLarge",
27-
"URLTooLong",
28-
"Unchunkable",
29-
"NoSitesError",
28+
"HTTPError",
3029
"TransientError",
3130
"RateLimited",
3231
"ServiceUnavailable",
32+
"RequestTooLarge",
33+
"URLTooLong",
34+
"Unchunkable",
35+
"NoDataError",
36+
"NoSitesError", # deprecated alias for NoDataError
37+
"error_for_status",
3338
]
3439

3540

3641
class DataRetrievalError(Exception):
37-
"""Base class for errors raised when a request to a USGS or EPA web
42+
"""Base class for every error raised when a request to a USGS or EPA web
3843
service fails.
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+
Service modules raise a subclass of this on a failed request, so a caller
46+
can handle them uniformly::
4347
4448
try:
4549
df, md = dataretrieval.wqp.get_results(...)
4650
except dataretrieval.DataRetrievalError:
4751
...
4852
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.
53+
Connection-level failures (timeouts, DNS) still surface as ``httpx``
54+
exceptions on the single-shot request paths.
55+
"""
56+
57+
def __reduce__(self) -> tuple[Any, ...]:
58+
# The status subclasses declare keyword-only fields (status_code /
59+
# retry_after); the default ``BaseException.__reduce__`` rebuilds via
60+
# ``cls(*self.args)``, which drops them and raises TypeError on unpickle
61+
# / deepcopy. Reconstruct from args + ``__dict__`` instead so every
62+
# subclass round-trips -- these get pickled back from multiprocessing /
63+
# lithops workers.
64+
return (_rebuild_error, (self.__class__, self.args, self.__dict__))
65+
66+
67+
def _rebuild_error(
68+
cls: type[DataRetrievalError],
69+
args: tuple[Any, ...],
70+
state: dict[str, Any],
71+
) -> DataRetrievalError:
72+
"""Rebuild a :class:`DataRetrievalError` without calling ``__init__``.
73+
74+
See :meth:`DataRetrievalError.__reduce__`.
75+
"""
76+
err = cls.__new__(cls)
77+
err.args = args
78+
err.__dict__.update(state)
79+
return err
80+
81+
82+
# --- HTTP status errors --------------------------------------------------
83+
84+
85+
class HTTPError(DataRetrievalError):
86+
"""The service returned an error HTTP status.
87+
88+
The numeric status is on :attr:`status_code`; inspect it to branch on the
89+
specific code, e.g. ``except HTTPError as e: ... e.status_code == 404``.
90+
:class:`TransientError` (429 / 5xx) is the retryable subset and is itself an
91+
``HTTPError``. The one carve-out: a 413/414 surfaces as :class:`URLTooLong`
92+
(a :class:`RequestTooLarge`), *not* an ``HTTPError`` -- catch
93+
:class:`DataRetrievalError` to span every failure.
94+
95+
Parameters
96+
----------
97+
message : str
98+
Human-readable error message.
99+
status_code : int
100+
The HTTP status the service returned.
101+
"""
102+
103+
def __init__(self, message: str, *, status_code: int) -> None:
104+
super().__init__(message)
105+
self.status_code = status_code
106+
107+
108+
class TransientError(HTTPError):
109+
"""A 429 or 5xx the server may serve on a later try (:class:`RateLimited`
110+
for 429, :class:`ServiceUnavailable` for 5xx).
111+
112+
This classifies the HTTP condition; it does not by itself retry the request.
113+
Whether a transient is retried is up to the calling path -- a single-shot
114+
request raises it for the caller to handle (e.g. wait :attr:`retry_after`
115+
and re-issue).
116+
117+
Parameters
118+
----------
119+
message : str
120+
Human-readable error message.
121+
status_code : int
122+
The HTTP status the service returned.
123+
status_code : int, optional
124+
The HTTP status the service returned. Defaults to the concrete leaf's
125+
canonical code (:attr:`_DEFAULT_STATUS`) when omitted;
126+
:func:`error_for_status` always passes the real status.
127+
retry_after : float, optional
128+
Seconds to wait before retrying, parsed from the ``Retry-After``
129+
response header; ``None`` when the header is absent or unparseable.
54130
"""
55131

132+
#: Canonical status a concrete transient stamps when built without an
133+
#: explicit ``status_code`` (:class:`RateLimited` = 429,
134+
#: :class:`ServiceUnavailable` = 503). ``TransientError`` itself is abstract
135+
#: and sets none, so constructing it bare requires ``status_code``.
136+
_DEFAULT_STATUS: ClassVar[int]
137+
138+
def __init__(
139+
self,
140+
message: str,
141+
*,
142+
status_code: int | None = None,
143+
retry_after: float | None = None,
144+
) -> None:
145+
super().__init__(
146+
message,
147+
status_code=self._DEFAULT_STATUS if status_code is None else status_code,
148+
)
149+
self.retry_after = retry_after
150+
56151

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.
152+
class RateLimited(TransientError):
153+
"""A request was rejected with HTTP 429 (too many requests)."""
61154

155+
_DEFAULT_STATUS = 429
62156

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

158+
class ServiceUnavailable(TransientError):
159+
"""A request was rejected with a server error (HTTP 5xx).
66160
67-
class NotFoundError(DataRetrievalError, ValueError):
68-
"""The requested resource was not found; often an empty query (HTTP 404)."""
161+
Raised by both the legacy ``query`` path and the Water Data path, so a 5xx
162+
surfaces as one type regardless of which subsystem issued the request.
163+
``status_code`` defaults to 503 (its namesake) only when built by hand
164+
without one; the factory always supplies the real 5xx.
165+
"""
69166

167+
_DEFAULT_STATUS = 503
70168

71-
class RequestTooLarge(DataRetrievalError, ValueError):
169+
170+
# --- Request can't fit (not necessarily an HTTP status) ------------------
171+
172+
173+
class RequestTooLarge(DataRetrievalError):
72174
"""The request is too large for the service to satisfy.
73175
74176
A base for the two ways a request can exceed what the service accepts;
@@ -99,56 +201,45 @@ class Unchunkable(RequestTooLarge):
99201
"""
100202

101203

102-
class NoSitesError(DataRetrievalError):
103-
"""The selection criteria matched no sites/data."""
204+
# --- Empty result --------------------------------------------------------
205+
206+
207+
class NoDataError(DataRetrievalError):
208+
"""The request succeeded (HTTP 200) but the selection criteria matched
209+
no data."""
104210

105211
def __init__(self, url: httpx.URL) -> None:
106212
self.url = url
107213

108214
def __str__(self) -> str:
109215
return (
110-
"No sites/data found using the selection criteria specified in "
111-
f"url: {self.url}"
216+
f"No data found using the selection criteria specified in url: {self.url}"
112217
)
113218

114219

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.
220+
#: Deprecated alias for :class:`NoDataError`. The original name leaked NWIS-era
221+
#: "sites" terminology; it is retained so existing ``except NoSitesError``
222+
#: handlers keep working, and will be removed in a future release.
223+
NoSitesError = NoDataError
121224

122225

123-
class TransientError(DataRetrievalError, RuntimeError):
124-
"""Base for transient HTTP failures that are worth an automatic retry.
226+
def error_for_status(
227+
status: int, message: str, *, retry_after: float | None = None
228+
) -> DataRetrievalError:
229+
"""Return the typed :class:`DataRetrievalError` for an HTTP error *status*.
125230
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
143-
144-
145-
class RateLimited(TransientError):
146-
"""A request was rejected with HTTP 429 (too many requests)."""
147-
148-
149-
class ServiceUnavailable(TransientError):
150-
"""A request was rejected with a server error (HTTP 5xx).
151-
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.
231+
The single status-to-type mapping shared by every request path (the legacy
232+
``query`` path, ``waterdata``, ``nadp`` / ``streamstats``), so a given status
233+
surfaces as the same type everywhere. ``message`` is used verbatim;
234+
``retry_after`` is attached only to the transient (:class:`TransientError`)
235+
types. A 413/414 surfaces as :class:`URLTooLong` (a :class:`RequestTooLarge`)
236+
rather than a generic :class:`HTTPError`, matching the client-side
237+
over-long-URL case.
154238
"""
239+
if status in (413, 414):
240+
return URLTooLong(message)
241+
if status == 429:
242+
return RateLimited(message, status_code=status, retry_after=retry_after)
243+
if 500 <= status < 600:
244+
return ServiceUnavailable(message, status_code=status, retry_after=retry_after)
245+
return HTTPError(message, status_code=status)

dataretrieval/nadp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545

4646
import httpx
4747

48-
from dataretrieval.utils import HTTPX_DEFAULTS
48+
from dataretrieval.utils import HTTPX_DEFAULTS, _raise_for_status
4949

5050
_DEPRECATION_MESSAGE = (
5151
"The `nadp` module is deprecated and will be removed from `dataretrieval` "
@@ -230,7 +230,7 @@ def get_zip(url: str, filename: str) -> NADP_ZipFile:
230230
_warn_deprecated()
231231

232232
req = httpx.get(url + filename, **HTTPX_DEFAULTS)
233-
req.raise_for_status()
233+
_raise_for_status(req)
234234

235235
# z = zipfile.ZipFile(io.BytesIO(req.content))
236236
z = NADP_ZipFile(io.BytesIO(req.content))

0 commit comments

Comments
 (0)