Skip to content

Fetch robustness: typed errors, stale-on-failure, and a request deadline - #108

Open
john-zither wants to merge 4 commits into
opendsm:masterfrom
john-zither:feature/fetch-robustness
Open

john-zither wants to merge 4 commits into
opendsm:masterfrom
john-zither:feature/fetch-robustness

Conversation

@john-zither

Copy link
Copy Markdown
Contributor

Your checklist for this pull request

  • Pulling a feature branch (feature/fetch-robustness) against power-source.
  • Tests pass with coverage up: 464 passed (23 new, covering the typed error, stale-on-failure, and the budget), ruff check clean. The 10 failures are pre-existing on power-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.
  • CHANGELOG.md updated under the "Development" section.
  • Code style: ruff.
  • New functions and the new FetchDeadlineExceeded carry numpy-style docstrings; load_data and WeatherStation.load_data parameter docs updated.
  • All commits carry the DCO Signed-off-by trailer.
  • Parts of this change were drafted with an AI assistant; every commit records it in a Co-Authored-By trailer. 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.ConnectionError or Timeout escapes load_data unchanged, so a caller must import requests to catch it — and no exception is exported at the top level, so it must know the submodule too. FetchError distinguishes "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_year calls store.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 — ghcnh three times, nasa_power four, base three 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 raises FetchDeadlineExceeded. Unbounded by default.

Details worth review on the deadline:

  • 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, and passing a deadline through it would break every third-party source. A context variable reaches them without their cooperation, is inherited by asyncio tasks, 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.
  • All three retry loops check the budget before opening a socket, cap the socket timeout at what remains, and never back off past the deadline. Capping the timeout is what makes it real — checking only between attempts would still let one hung read run 120 seconds over.
  • The guarantee is approximate at the edge and the docstring says so: requests times out per socket operation, not per response, so a slow trickle can outlast it. Overshoot is bounded at one timeout rather than tries × timeout.
  • A deadline raises rather than returning what it collected. Partial weather that looks complete is the failure this exists to make visible.
  • remaining() clamps to the budget it was given: 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 — 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-local time.sleep now patch it there. That is the only behavioural change to existing paths.

@travis-recurve
travis-recurve force-pushed the feature/fetch-robustness branch from 7edf46f to 4d59c07 Compare September 9, 2026 19:25
@travis-recurve
travis-recurve deleted the branch opendsm:master September 9, 2026 19:26
@travis-recurve travis-recurve reopened this Sep 9, 2026
@travis-recurve
travis-recurve changed the base branch from feature/power-source to master September 9, 2026 19:40
@travis-recurve
travis-recurve force-pushed the feature/fetch-robustness branch from 4d59c07 to bb4a70e Compare September 9, 2026 19:40
@travis-recurve

Copy link
Copy Markdown
Contributor

Good work, and the hazard is real. The context variable is the right mechanism, and the
registry auto-updater is indeed the only thread in the package.

Two changes before merge.

  1. The stale-on-failure serve is invisible. load_year returns a stale block on
    FetchError with no warning and no provenance record, which contradicts the argument you
    make for the deadline in the same PR ("partial weather that looks complete is the failure
    this is meant to make visible"). Stale weather that looks fresh is the same failure. I
    would record it in Provenance rather than a warning, since staleness is a property of
    the data.

  2. deadline does not reach WeatherLocation, which is the primary entry point and does
    not route through engine.load_data. Wrapping the call in fetch_budget works, but is
    undocumented and buried.

Minor: FetchError and fetch_budget are not exported at the top level, though the
docstring names that as part of the problem. request_text passes the literal "request"
as the source name, so failures read "Could not fetch request data for station=None". One
blank line between the exception classes where the file uses two.

The branch was rebased from our side after #105 merged, and briefly closed when the base
branch was deleted. Your commits are unchanged, but you will need
git fetch && git reset --hard origin/feature/fetch-robustness.

john-zither pushed a commit to john-zither/eeweather that referenced this pull request Sep 12, 2026
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
@john-zither

Copy link
Copy Markdown
Contributor Author

Rebased and pushed. All four:

  1. Stale serves now show up in Provenance. Added a stale field to the namedtuple (trailing, defaults to False). When load_year serves a stale block after a FetchError, it records that in a small ContextVar ledger keyed by (station_id, year), the same pattern as fetch_budget. engine.py reads the ledger when it builds Provenance. This only covers the station pipeline — NasaPowerSource._load_year has no stale path, so there's nothing to record for grid loads.
  2. deadline reaches WeatherLocation now. Gave WeatherLocation.load_data a deadline param like WeatherStation.load_data has, and wrapped both estimate(...) calls in fetch_budget(deadline). It no longer relies on the engine.load_data wrap.
  3. Exports. FetchError, FetchDeadlineExceeded, and fetch_budget are exported at the top level.
  4. request_text. It takes source/station_id/year now and passes them into FetchError, and the ghcnh/tmy3/cz2010/NASA callers fill them in. Failures name the real source and station instead of station=None. Also fixed the blank line between the exception classes.

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>
john-zither pushed a commit to john-zither/eeweather that referenced this pull request Sep 12, 2026
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
@john-zither
john-zither force-pushed the feature/fetch-robustness branch from cb582e4 to a778cea Compare September 12, 2026 11:54
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>
@john-zither
john-zither force-pushed the feature/fetch-robustness branch from a778cea to 0b4c537 Compare September 13, 2026 22:14

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants