|
| 1 | +"""Exception taxonomy for ``dataretrieval``. |
| 2 | +
|
| 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. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +__all__ = [ |
| 18 | + "DataRetrievalError", |
| 19 | + "BadRequestError", |
| 20 | + "NotFoundError", |
| 21 | + "RequestTooLarge", |
| 22 | + "URLTooLong", |
| 23 | + "Unchunkable", |
| 24 | + "NoSitesError", |
| 25 | + "TransientError", |
| 26 | + "RateLimited", |
| 27 | + "ServiceUnavailable", |
| 28 | +] |
| 29 | + |
| 30 | + |
| 31 | +class DataRetrievalError(Exception): |
| 32 | + """Base class for errors raised when a request to a USGS or EPA web |
| 33 | + service fails. |
| 34 | +
|
| 35 | + Every service module (``nwis``, ``wqp``, ``waterdata``, ``nldi``, ...) |
| 36 | + raises a subclass of this when a request fails, so a caller can handle any |
| 37 | + request failure uniformly:: |
| 38 | +
|
| 39 | + try: |
| 40 | + df, md = dataretrieval.wqp.get_results(...) |
| 41 | + except dataretrieval.DataRetrievalError: |
| 42 | + ... |
| 43 | +
|
| 44 | + Subclasses also inherit from the built-in exception this package has |
| 45 | + historically raised for the condition's *kind* -- :class:`ValueError` for a |
| 46 | + request that can't succeed as written (bad params, too large), and |
| 47 | + :class:`RuntimeError` for a transient transport failure -- so existing |
| 48 | + ``except ValueError`` / ``except RuntimeError`` handlers keep working. |
| 49 | + """ |
| 50 | + |
| 51 | + |
| 52 | +# --- Fatal client errors ------------------------------------------------- |
| 53 | +# The request can't succeed as written; retrying it unchanged won't help. Each |
| 54 | +# is also a ``ValueError`` -- the built-in the legacy ``query`` path has always |
| 55 | +# raised -- so existing ``except ValueError`` handlers keep working. |
| 56 | + |
| 57 | + |
| 58 | +class BadRequestError(DataRetrievalError, ValueError): |
| 59 | + """The service rejected the request parameters (HTTP 400).""" |
| 60 | + |
| 61 | + |
| 62 | +class NotFoundError(DataRetrievalError, ValueError): |
| 63 | + """The requested resource was not found; often an empty query (HTTP 404).""" |
| 64 | + |
| 65 | + |
| 66 | +class RequestTooLarge(DataRetrievalError, ValueError): |
| 67 | + """The request is too large for the service to satisfy. |
| 68 | +
|
| 69 | + A base for the two ways a request can exceed what the service accepts; |
| 70 | + catch it to handle either. The concrete subclasses are :class:`URLTooLong` |
| 71 | + (a single request the server rejected) and :class:`Unchunkable` (the Water |
| 72 | + Data chunker could not split the call small enough to fit). |
| 73 | + """ |
| 74 | + |
| 75 | + |
| 76 | +class URLTooLong(RequestTooLarge): |
| 77 | + """A single request URL exceeded the service's limit (HTTP 414, or rejected |
| 78 | + client-side before it was sent). |
| 79 | +
|
| 80 | + Raised by the legacy ``query`` path, which issues one request without |
| 81 | + chunking. Remediation: query fewer sites, or split the call manually. |
| 82 | + """ |
| 83 | + |
| 84 | + |
| 85 | +class Unchunkable(RequestTooLarge): |
| 86 | + """No chunking plan fits the URL byte limit. |
| 87 | +
|
| 88 | + Raised by the Water Data chunker when even the smallest reducible plan |
| 89 | + (every list axis at one atom per sub-request, the filter at one clause per |
| 90 | + sub-request) still exceeds the server's byte limit -- so unlike |
| 91 | + :class:`URLTooLong`, automatic splitting has already been tried and |
| 92 | + exhausted. Shrink the input lists, simplify the filter, or split the call |
| 93 | + manually. |
| 94 | + """ |
| 95 | + |
| 96 | + |
| 97 | +class NoSitesError(DataRetrievalError): |
| 98 | + """The selection criteria matched no sites/data.""" |
| 99 | + |
| 100 | + def __init__(self, url): |
| 101 | + self.url = url |
| 102 | + |
| 103 | + def __str__(self): |
| 104 | + return ( |
| 105 | + "No sites/data found using the selection criteria specified in " |
| 106 | + f"url: {self.url}" |
| 107 | + ) |
| 108 | + |
| 109 | + |
| 110 | +# --- Transient transport errors ------------------------------------------ |
| 111 | +# The service was reachable but temporarily refused the request; the same call |
| 112 | +# may succeed if retried. Each is also a ``RuntimeError`` (the built-in the |
| 113 | +# waterdata path has always raised). The Water Data chunker recognizes them via |
| 114 | +# ``isinstance(exc, TransientError)`` and wraps them as resumable |
| 115 | +# ``ChunkInterrupted`` subclasses. |
| 116 | + |
| 117 | + |
| 118 | +class TransientError(DataRetrievalError, RuntimeError): |
| 119 | + """Base for transient HTTP failures that are worth an automatic retry. |
| 120 | +
|
| 121 | + One subclass per recoverable HTTP status family (429 -> :class:`RateLimited`, |
| 122 | + 5xx -> :class:`ServiceUnavailable`); the Water Data chunker recognizes them |
| 123 | + by this shared base and wraps them as resumable interruptions. |
| 124 | +
|
| 125 | + Parameters |
| 126 | + ---------- |
| 127 | + message : str |
| 128 | + Human-readable error message. |
| 129 | + retry_after : float, optional |
| 130 | + Seconds to wait before retrying, parsed from the ``Retry-After`` |
| 131 | + response header; stored on the :attr:`retry_after` attribute (``None`` |
| 132 | + when the header is absent or unparseable). |
| 133 | + """ |
| 134 | + |
| 135 | + def __init__(self, message: str, *, retry_after: float | None = None) -> None: |
| 136 | + super().__init__(message) |
| 137 | + self.retry_after = retry_after |
| 138 | + |
| 139 | + |
| 140 | +class RateLimited(TransientError): |
| 141 | + """A request was rejected with HTTP 429 (too many requests).""" |
| 142 | + |
| 143 | + |
| 144 | +class ServiceUnavailable(TransientError): |
| 145 | + """A request was rejected with a server error (HTTP 5xx). |
| 146 | +
|
| 147 | + Raised by both the legacy ``query`` path and the Water Data path, so a 5xx |
| 148 | + surfaces as one type regardless of which subsystem issued the request. |
| 149 | + """ |
0 commit comments