Skip to content

fix(nwis): handle empty peaks response instead of raising KeyError - #344

Merged
thodson-usgs merged 5 commits into
DOI-USGS:mainfrom
arpitjain099:chore/nwis-empty-peaks
Sep 2, 2026
Merged

fix(nwis): handle empty peaks response instead of raising KeyError#344
thodson-usgs merged 5 commits into
DOI-USGS:mainfrom
arpitjain099:chore/nwis-empty-peaks

Conversation

@arpitjain099

Copy link
Copy Markdown
Contributor

Calling nwis.get_discharge_peaks or get_record(service="peaks") for a site with no annual-peak data raises KeyError('peak_dt') instead of returning an empty result.

When peaks has no data, the RDB body is comment lines only, so read_rdb parses it to a column-less empty DataFrame. format_response runs preformat_peaks_response before its own "datetime not in columns" empty-frame check, and that function's first line pops peak_dt, which blows up on the empty frame.

This is the same empty-result behavior that #171 fixed for the other services; peaks slipped through because it gets preformatted first. The fix returns the frame unchanged when peak_dt is absent, so the existing empty-frame path in format_response takes over and callers can check df.empty rather than catching an exception.

Added a regression test in TestReadRdb next to the existing #171 coverage. It fails on main with KeyError('peak_dt') and passes with the change. ruff check and ruff format --check are clean on both touched files.

I work on supply-chain and data-tooling robustness and hit this while looking at the empty-response paths. Happy to adjust if you would rather guard this inside format_response instead.

arpitjain099 and others added 3 commits July 17, 2026 06:03
nwis.get_discharge_peaks / get_record(service="peaks") against a site
with no annual-peak data returns a peaks RDB body of comment lines only,
which read_rdb parses to a column-less empty DataFrame. format_response
runs preformat_peaks_response before its own empty-frame check, and that
function's first statement pops "peak_dt", so the empty case raised
KeyError('peak_dt') instead of returning an empty frame.

This is the same empty-result contract fixed for the other services in
issue DOI-USGS#171; peaks was missed because it is preformatted first. Return the
frame unchanged when peak_dt is absent so the empty-frame path in
format_response handles it and callers can check df.empty.

Adds a regression test alongside the existing DOI-USGS#171 coverage.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
The empty-peaks test parsed with _read_rdb, which already runs
format_response(service=None); the real get_discharge_peaks path uses
the raw read_rdb parser followed by format_response(service="peaks").
Switch to read_rdb so the test exercises the actual call path without
the redundant format pass.

Signed-off-by: thodson-usgs <thodson@usgs.gov>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Conflict was in tests/nwis_test.py, where main added TestGetRecordDispatch
directly after the class this branch appends to; both sides kept.

read_rdb has since moved to the dataretrieval.rdb leaf, so the test imports
it from there rather than through the nwis adapter, and passes
_NWIS_RDB_DTYPES to mirror what get_discharge_peaks now does.

Retarget the test docstring at the defect that is actually reachable.
get_discharge_peaks does not raise KeyError: every empty peaks response the
live service returns starts "No sites/data", which _querying turns into
NoSitesError before format_response is reached. The real gap is that
format_response and preformat_peaks_response are public API and crash on a
column-less frame, where every other service yields an empty one (issue DOI-USGS#171).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thodson-usgs

Copy link
Copy Markdown
Collaborator

Thanks for this — the fix is right and I've merged current main into the branch and pushed. Net change is still just the 7-line guard plus one regression test.

One correction to the premise, because I couldn't reproduce the headline symptom and I'd rather the test docstring not enshrine a repro that doesn't hold.

get_discharge_peaks doesn't actually raise KeyError. Every genuinely empty peaks response the live service returns is the legacy sentinel body, which _querying converts to NoSitesError before format_response is reached. I probed six cases against nwis.waterdata.usgs.gov/nwis/peaks:

case body
streamgage, empty future window No sites/data found…NoSitesError
groundwater-only site No sites/data found…NoSitesError
nonexistent site No sites/data found…NoSitesError
streamgage, pre-record window HTML error page → ValueError from read_rdb
stateCd=HI, narrow window 651 KB of data (peaks ignores the date filter)
control, site with peaks normal RDB

So the getter path is already guarded.

The defect is real anyway, one level down. format_response and preformat_peaks_response are both public, documented API (automodule :members:), and on current main:

read_rdb(comment_only_rdb) -> (0, 0) empty frame
format_response(df, service="peaks") -> KeyError: 'peak_dt'
format_response(df, service=None)    -> OK, empty frame

That asymmetry is exactly what issue #171 established shouldn't happen, and peaks is the one arm that still violates it — because preformat_peaks_response runs before the "datetime" not in df.columns check. So the guard belongs where you put it. I've reworded the test docstring to describe that path rather than the getter.

This also sits inside ADR 0005, which permits "compatibility, security, and correctness fixes only" on the deprecated nwis facade — a correctness fix, no new capability.

What I changed while resolving:

  • read_rdb has since moved to the dataretrieval.rdb leaf, so the test imports it from there rather than through the nwis adapter, and passes _NWIS_RDB_DTYPES to mirror what get_discharge_peaks now does.
  • Trimmed the inline comment to the constraint; the history lives in the commit message.
  • Conflict was TestGetRecordDispatch landing right where this branch appends; both sides kept.

Verified: the new test fails with KeyError: 'peak_dt' without the guard and passes with it; 1130 tests pass; mypy --strict, ruff, Xenon, complexipy and import-linter all clean.

One thing I deliberately left alone: there are now two different empty-result behaviors for NWIS — NoSitesError when the body starts with the sentinel, an empty frame when the RDB is comment-only. That predates this PR and isn't its job to reconcile, but it's worth a follow-up issue.

Review follow-ups on the empty-peaks guard.

The guard fired on any frame missing peak_dt, including a non-empty one from
a truncated or altered RDB header. Such a frame is malformed rather than
empty, and was being returned silently without its datetime index where it
used to raise. Require df.empty too, and pin it with a test.

Correct the regression test's docstring, which said the empty responses
get_discharge_peaks sees are caught earlier as NoSitesError. That check fires
only on a body starting "No sites/data" -- what the live service happens to
send today -- so a comment-only RDB does reach the guarded line. As written
the docstring read as "this guard is unreachable", inviting its deletion.

Widen the test class docstring to cover both arms it now holds, document the
pass-through in preformat_peaks_response's public docstring, and add the
NEWS entry this behavior change warrants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thodson-usgs

Copy link
Copy Markdown
Collaborator

Ran a review pass over the branch and pushed the follow-ups. No functional bug in the fix itself; these are contract and documentation issues.

The guard was broader than intended. if "peak_dt" not in df.columns also fired on a non-empty frame missing that column — a truncated or altered RDB header. That frame is malformed rather than empty, and was being returned silently without its datetime index where it previously raised. Now if df.empty and "peak_dt" not in df.columns, with a test pinning that a malformed non-empty frame still raises.

I had overstated the NoSitesError guard in the test docstring I wrote on the last push. I said the empty responses get_discharge_peaks sees are caught earlier as NoSitesError — but that check fires only on a body starting No sites/data, which is what the live service happens to send today. A comment-only RDB does reach the guarded line. As written the docstring read as "this guard is unreachable", which invites someone deleting it. Reworded.

Also: widened the test class docstring so it still describes its contents, documented the pass-through in preformat_peaks_response's public docstring, and added the NEWS entry this behavior change warrants.

Verified: 1131 tests pass; mypy --strict, ruff, Xenon, complexipy and import-linter all clean.

One thing I deliberately left out: a mocked test driving get_discharge_peaks end-to-end. It would pass, but it pins a response shape the live service doesn't produce — I probed six empty cases and every one returns the No sites/data sentinel. The unit-level test covers the path callers actually reach.

Separate follow-up, not folded in here: rdb.read_rdb has the same empty-result hole one step earlier — read_rdb("# //Output-Format: RDB\n#\n\n") raises pandas.errors.EmptyDataError rather than returning an empty frame, because header_idx lands on the blank line. Confirmed, but it lives in the shared rdb leaf, so it belongs in its own change rather than growing this one.

@arpitjain099

Copy link
Copy Markdown
Contributor Author

@thodson-usgs Your narrowing is right. df.empty and "peak_dt" not in df.columns is the correct guard. Mine fired on a malformed non-empty frame as well and would have handed it back silently without its datetime index.

On the conflicts: I resolved them locally and the branch comes out empty against main. #395 already carries the guard and an equivalent malformed-frame test, and merging this as-is would revert the censored-peaks work. It restores df.pop("peak_dt") and the dropna(subset=["datetime"]) that #395 removed, and drops the three peaks tests. So I'd close this as superseded unless you want it open for something.

Happy to take the rdb.read_rdb follow-up you flagged. read_rdb("# //Output-Format: RDB\n#\n\n") raising EmptyDataError because header_idx lands on the blank line is the same hole one layer down, and it is self-contained enough for its own change. I'll open an issue for it first unless you'd rather go straight to a patch.

Main absorbed this PR's empty-peaks guard via DOI-USGS#395 (censored peaks), so the
code and NEWS conflicts resolve to upstream wholesale — keeping this branch's
version of preformat_peaks_response would have reverted DOI-USGS#395's keep-censored-
peaks behavior (it popped peak_dt and dropped NaT rows). The branch's
remaining net contribution is the malformed-frame regression test; its
duplicate empty-peaks test (now redundant with main's) and the
_NWIS_RDB_DTYPES import it needed are dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
@thodson-usgs

Copy link
Copy Markdown
Collaborator

@arpitjain099 , the bot found #395 while reviewing your PR, but I merged out of order and it clobbered your fix. Sorry about that. I trimmed this PR to a regression test rather than closing it. Thanks for finding the bug!

@thodson-usgs
thodson-usgs merged commit b146c0e into DOI-USGS:main Sep 2, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants