Repository navigation
Fetch robustness: typed errors, stale-on-failure, and a request deadline - #108
john-zither wants to merge 4 commits into
Conversation
8544807 to
569351d
Compare
7edf46f to
4d59c07
Compare
4d59c07 to
bb4a70e
Compare
|
Good work, and the hazard is real. The context variable is the right mechanism, and the Two changes before merge.
Minor: The branch was rebased from our side after #105 merged, and briefly closed when the base |
Address the opendsm#108 review. Record the stale serve in provenance. load_year serves a stale cached block on a transport failure, which was invisible in the returned frame. It now notes the served year in a context-local ledger; the engine collects that around each source's load and records it as Provenance.stale, since staleness is a property of the data, not a warning. (The NASA POWER grid loader has no stale-on-failure path, so this is the station pipeline only.) Make the deadline reach WeatherLocation. WeatherLocation.load_data is the primary entry point and routes around engine.load_data, where the sole fetch_budget wrap lived, so a location load -- the common case -- was unbounded. It now takes a documented deadline and wraps both the pinned and routed estimate paths in fetch_budget; the ghcnh and NASA POWER fetches both consult the budget, so it bounds station and grid loads alike. Minor. Export FetchError, FetchDeadlineExceeded, and fetch_budget at the top level, so a caller need not know the submodule. request_text takes the source name and station, so a transport failure reads "Could not fetch tmy3 data for station=<id>" rather than "request data for station=None". One blank line restored between the two new exception classes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZSvzSCixXWkses63bw6ic
|
Rebased and pushed. All four:
|
A `requests.ConnectionError` or `Timeout` escaped `load_data` unchanged, so a caller had to import requests to catch a weather failure -- and since `eeweather.__all__` exports no exception at all, it had to know the submodule too. `exceptions.py` already had an `EEWeatherError` base that nothing used for transport. Adds `FetchError(EEWeatherError)`, carrying the source, station id, year and originating exception, with a `status_code` property for the cases where the HTTP status is the message. It is raised by the GHCNh, NASA POWER and normals transports and by the shared `request_text` retry loop. What this separates is worth stating plainly: "the network failed" and "the data does not exist" are different answers. One is worth retrying and the other never will be. `DataNotAvailableError` keeps meaning the second, and `NormalsSource.fetch` still maps a 404 to it -- reading the status through `FetchError.status_code` rather than catching `requests.HTTPError`, which a typed error would otherwise have bypassed. For a fleet run this is the difference between a transient 503 killing the job and it being distinguishable from a bug. Downstream, epathermostat's importer carries a bare `except Exception` partly because eeweather's fetch failures were untyped, so every typo and schema mistake became a silently missing output row. `request_text` keeps its single-argument signature; the source name is a generic "request" rather than a new parameter, so existing test doubles that take one argument still work. Verified: full suite green against the 10 pre-existing failures on power-source, no new failures; ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Theurer <john@energy-data-solutions.com>
`load_year` refreshes a cached year whose tail may still be arriving. If that refetch failed the failure propagated, even though the cached block was sitting right there and answered the request. The sequencing made it worse than a lost refresh. `_read_cached_year` called `store.clear(key)` on an expired entry and *then* returned None, so by the time the fetch ran the usable data was already gone -- a failed refresh destroyed data rather than merely failing to improve it. Now a `FetchError` during refetch returns the stale block, provided it holds every requested variable. The data is real; only its freshness is in doubt, and the reason it was being refreshed is that the year may still be receiving records. A caller asking for 2007 in 2026 would rather have last week's copy of a fifteen-year-old year than an exception. Deliberately narrow. Only a transport failure degrades this way: `DataNotAvailableError` and a malformed response still raise, because those say something about the data rather than about the network. And a stale block missing a requested variable still raises rather than returning a short frame, so a caller never silently receives fewer columns than it asked for. Depends on the typed `FetchError`, which is what makes "the network failed" distinguishable from "the data is not there" -- without it this could only have been written as a bare `except Exception`, which would have swallowed exactly the cases that must still raise. Verified: full suite green against the 10 pre-existing failures on power-source, no new failures; ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Theurer <john@energy-data-solutions.com>
Every fetch path retries with a 120 second socket timeout and nothing caps the total: ghcnh 3 tries x 120s nasa_power 4 tries x 120s base 3 tries x 120s (normals archives) Per station-year, per site. A caller running a fleet against an unresponsive upstream does not get a failure it can see; it gets a job that runs for days, which is worse than one that stops, because nobody is watching a batch job at 3am to notice it has not finished. load_data(..., deadline=<seconds>) bounds it and raises FetchDeadlineExceeded when it runs out. Unbounded by default, which is what it has always been. The budget is ambient -- a ContextVar in sources/budget.py -- rather than an argument threaded through the call chain, because fetch_year is a published extension point that sources.register invites anyone to implement. Passing a deadline through it would break every third-party source. A context variable reaches them without their cooperation, is inherited correctly by asyncio tasks, and is *not* inherited by threads, which is exactly right here: the registry auto-updater runs on its own thread and has no business inside a caller's request budget. All three retry loops now check the budget before opening a socket, cap the socket timeout at what is left, and never back off past the deadline. Bounding the socket timeout is what makes this real: checking only between attempts would still let a single hung read run 120 seconds past the deadline. The guarantee is approximate at the edge and the docstring says so: requests applies a timeout per socket operation, not to a whole response, so a slow trickle of bytes can still outlast it. Overshoot is at most one timeout rather than tries x timeout. remaining() clamps to the budget it was given. Without that, deadline - now is floating point, and where the monotonic clock reads the same value twice -- as it does wherever the counter is coarser than the gap between two calls, Windows among them -- the subtraction rounds above the budget rather than below it. For a monotonic base near 1000.0 that happens every time, so it is a platform difference rather than a flake. Nothing downstream cared about 40 picoseconds, but "never longer than the budget" should be an invariant rather than nearly true. A deadline raises rather than returning what it managed to collect. Deliberately: partial weather that looks complete is the failure mode this exists to make visible. Backoff moved into budget.sleep_within, so the three tests that observed retries by patching a module-local time.sleep now patch it there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: John Theurer <john@energy-data-solutions.com>
Address the opendsm#108 review. Record the stale serve in provenance. load_year serves a stale cached block on a transport failure, which was invisible in the returned frame. It now notes the served year in a context-local ledger; the engine collects that around each source's load and records it as Provenance.stale, since staleness is a property of the data, not a warning. (The NASA POWER grid loader has no stale-on-failure path, so this is the station pipeline only.) Make the deadline reach WeatherLocation. WeatherLocation.load_data is the primary entry point and routes around engine.load_data, where the sole fetch_budget wrap lived, so a location load -- the common case -- was unbounded. It now takes a documented deadline and wraps both the pinned and routed estimate paths in fetch_budget; the ghcnh and NASA POWER fetches both consult the budget, so it bounds station and grid loads alike. Minor. Export FetchError, FetchDeadlineExceeded, and fetch_budget at the top level, so a caller need not know the submodule. request_text takes the source name and station, so a transport failure reads "Could not fetch tmy3 data for station=<id>" rather than "request data for station=None". One blank line restored between the two new exception classes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZSvzSCixXWkses63bw6ic
cb582e4 to
a778cea
Compare
Address the opendsm#108 review. Record the stale serve in provenance. load_year serves a stale cached block on a transport failure, which was invisible in the returned frame. It now notes the served year in a context-local ledger; the engine collects that around each source's load and records it as Provenance.stale, since staleness is a property of the data, not a warning. (The NASA POWER grid loader has no stale-on-failure path, so this is the station pipeline only.) Make the deadline reach WeatherLocation. WeatherLocation.load_data is the primary entry point and routes around engine.load_data, where the sole fetch_budget wrap lived, so a location load -- the common case -- was unbounded. It now takes a documented deadline and wraps both the pinned and routed estimate paths in fetch_budget; the ghcnh and NASA POWER fetches both consult the budget, so it bounds station and grid loads alike. Minor. Export FetchError, FetchDeadlineExceeded, and fetch_budget at the top level, so a caller need not know the submodule. request_text takes the source name and station, so a transport failure reads "Could not fetch tmy3 data for station=<id>" rather than "request data for station=None". One blank line restored between the two new exception classes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZSvzSCixXWkses63bw6ic Signed-off-by: John Theurer <john@energy-data-solutions.com>
a778cea to
0b4c537
Compare
Your checklist for this pull request
feature/fetch-robustness) againstpower-source.ruff checkclean. The 10 failures are pre-existing onpower-source(pandas 2.x with numpy 2.5) and are fixed by Stop constructing Timedeltas the way numpy 2.5 deprecates #106; they are unrelated to this change.FetchDeadlineExceededcarry numpy-style docstrings;load_dataandWeatherStation.load_dataparameter docs updated.Signed-off-bytrailer.Co-Authored-Bytrailer. The tool's terms impose no conditions inconsistent with the project's licence or the Open Source Definition, and the output does not incorporate third-party code.Description
Three independent defects in what happens when a fetch goes wrong, as three commits.
Transport failures are untyped. A
requests.ConnectionErrororTimeoutescapesload_dataunchanged, so a caller must importrequeststo catch it — and no exception is exported at the top level, so it must know the submodule too.FetchErrordistinguishes "the network failed" from "the data does not exist" (DataNotAvailableError), which is the difference between a request worth retrying and one that never will be.A failed refetch destroys usable data.
_read_cached_yearcallsstore.clear(key)on an expired entry and then returns None, after which the caller fetches; if that fetch fails the old block is already gone. This is not a rare path — entries expire only for volatile years, the current one and the tail of the previous, which is exactly the window a recent reporting period reads from. The common case is an expired-but-good entry, an upstream outage, and the library discarding the local copy before finding out. The block is now replaced only after a successful refetch.Nothing caps how long a request may spend on the network. Every fetch path retries with a 120 second socket timeout —
ghcnhthree times,nasa_powerfour,basethree for normals archives — per station-year, per site. Against an unresponsive upstream a large run does not fail in a way anyone can see; it runs for days.load_data(deadline=<seconds>)bounds it and raisesFetchDeadlineExceeded. Unbounded by default.Details worth review on the deadline:
ContextVarinsources/budget.py— rather than an argument threaded through the call chain, becausefetch_yearis a published extension point thatsources.registerinvites anyone to implement, and passing a deadline through it would break every third-party source. A context variable reaches them without their cooperation, is inherited byasynciotasks, and is not inherited by threads, which is what we want here: the registry auto-updater runs on its own thread and has no business inside a caller's request budget.requeststimes out per socket operation, not per response, so a slow trickle can outlast it. Overshoot is bounded at one timeout rather than tries × timeout.remaining()clamps to the budget it was given:deadline - nowis floating point, and where the monotonic clock reads the same value twice — as it does wherever the counter is coarser than the gap between two calls — the subtraction rounds above the budget. Nothing downstream cared, but "never longer than the budget" should be an invariant.Backoff moved into
budget.sleep_within, so the three tests that observed retries by patching a module-localtime.sleepnow patch it there. That is the only behavioural change to existing paths.