Skip to content

Make errors actionable for programmatic callers, and refresh AGENTS.md - #387

Draft
thodson-usgs wants to merge 18 commits into
DOI-USGS:mainfrom
thodson-usgs:docs/refresh-agents-context
Draft

Make errors actionable for programmatic callers, and refresh AGENTS.md#387
thodson-usgs wants to merge 18 commits into
DOI-USGS:mainfrom
thodson-usgs:docs/refresh-agents-context

Conversation

@thodson-usgs

@thodson-usgs thodson-usgs commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Two related changes: the agent-facing docs were stale, and the error messages those docs point at often told a caller what went wrong without telling it what to do.

1. Errors say what to do, not just what went wrong

Most callers of this package are programs — a script, a pipeline stage, an agent. A message is the only channel through which such a caller can correct itself, and "Service not recognized" gives it nothing: it cannot enumerate the services, cannot tell whether a retry is worth attempting, and cannot find the argument it left out.

The taxonomy already knew this in places — transport.pagination.paginated_failure_message() spells out To recover: …, and the configuration errors name the table to write and the keys it accepts. The adapters mostly did not. This applies the same standard outward from the transport layer.

Shared checks. _validation.py owned one of the three checks that recur across adapters — a value outside a closed vocabulary. The other two, a missing argument and arguments that conflict, were hand-written at ~20 sites in as many phrasings, almost none naming a fix. They now join require_one_of:

helper for
require_argument an argument that must be supplied
require_together a group that means nothing half-supplied (lat/long)
require_exactly_one a choice between sufficient alternatives
reject_together arguments that conflict but are jointly optional

require_argument returns the value it validated. Validating in one place and then re-testing for None to satisfy mypy leaves a second, unreachable message beside the first, and two messages for one condition drift apart.

Before / after:

- Service not recognized
+ Unrecognized service: None. query_waterservices serves 'dv', 'iv', 'site', 'stat'.

- Both lat and long are required
+ lat and long must be given together to navigate from a point. Missing: long.
+ Pass both, e.g. lat=43.087, long=-89.509.

- Specify one origin type - comid or feature_source is required
+ Provide exactly one of comid or feature_source as the origin to navigate from.
+ Supplied: none. Navigate from a comid, e.g. comid=13294314, or from a
+ feature_source/feature_id pair -- not both, and not neither.

- One or more lat/long coordinates missing or invalid.
+ A bounding box needs all four corners. Missing: nw_latitude_va, se_longitude_va,
+ se_latitude_va. Pass them, or drop the bounding box and filter with
+ site_no or stateCd instead.

- Install geopandas to use the NLDI module.
+ The NLDI module requires geopandas, which is not installed.
+ Install it with `pip install dataretrieval[nldi]`.

Also covered: NLDI's fourteen origin checks, the NWIS service rejections, and single messages in nwdc, ogc/dates, rdb, wqp, codes/states, waterdata/nearest and waterdata/ratings.

⚠️ Flagged, not changed. nldi._query_nldi turns a 200 with a non-JSON body into an empty GeoDataFrame. A caller cannot distinguish that from a genuine empty result, which is the worst case for an autonomous one — but it is a documented contract (_features_to_gdf: "return an empty GeoDataFrame … instead of crashing"), not an oversight, so changing it is your call rather than a side effect of this PR. It is recorded in AGENTS.md as the exception to the rule it breaks.

Tests assert the remedy, not the sentence: what must hold is that the message names a move the caller can make. Pinning exact phrasing would freeze the wording and test nothing that matters.

2. AGENTS.md refresh

Last touched 37 commits ago, and five statements were no longer true:

Claim Actual
dataretrieval/waterdata/chunking.py moved to dataretrieval/ogc/chunking.py
waterdata.utils._default_headers() now transport.http.default_headers(); the credential is owned by the credentials leaf and scoped to its host
tests skip on Python <3.10 requires-python = ">=3.10"; no version skips remain
tests/nwis_test.py::test_nwis_service_live gone — live tests use @pytest.mark.live, deselected by addopts and run by live-api.yml
notebook inventory omitted USGS_NGWMN_Examples.ipynb while asserting anything unlisted was scratch

It also never pointed at CONTEXT.md, and predated the structural gates (lint-imports, xenon, complexipy, mypy --strict) — so a change could pass the tests and still fail the merge. Rewritten around where a thing lives and by what rule, with .importlinter named as the authoritative map since its layers contract is exhaustive = True. Adds an Error messages section recording the convention above.

3. CONTEXT.md

Verified accurate throughout — every glossary claim still matches the code. One exception: the Setting entry cited ssl_check, which is a plain getter kwarg on four adapters and resolves through no chain. Replaced with settings that are in the roster, plus the distinction it was blurring.

Testing

1015 passed. All merge gates clean locally: ruff check/format, mypy (strict), lint-imports (8 contracts), xenon, complexipy.

🤖 Generated with Claude Code

@thodson-usgs thodson-usgs changed the title docs: refresh AGENTS.md and correct the setting definition Make errors actionable for programmatic callers, and refresh AGENTS.md Aug 20, 2026
thodson-usgs and others added 5 commits August 20, 2026 13:31
AGENTS.md was 37 commits stale and never mentioned CONTEXT.md, so an agent
reading it missed the shared vocabulary entirely. It also described a file
layout that has since moved, which is worse than saying nothing: a wrong map
is followed confidently.

Replace the layout listing with the placement *logic* -- adapters are named
for their service, shared machinery below them names none, `.importlinter`'s
exhaustive `layers` contract is the authoritative map -- so an agent can
predict where a thing lives and use `ls`/`grep` for the rest rather than
carrying a file list in context.

Corrections to claims that had drifted: the CI matrix is 3.10/3.13/3.14, and
`__init__.py` imports the service modules by name rather than star-importing
them, so a getter is reached through its module and `dataretrieval.get_record`
is an AttributeError.

In CONTEXT.md, the Setting entry implied every setting applies everywhere.
It does not -- `concurrency` is meaningless to an adapter that issues one
request at a time -- and `ssl_check` is a getter argument on four adapters
rather than a setting at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_validation.py` exists because a check copied between adapters brings its
message along: `get_reference_table` told callers who passed a bad
`collection` that their *code service* was invalid, because the check came
from `samples.py` with the noun unchanged. It owned the wording for one
shape -- a value outside a closed vocabulary -- while two others recurred at
some twenty sites in as many phrasings, most naming no remedy at all.

Add the missing shapes: a missing argument (`require_argument`), an
all-or-none group (`require_together`), a choice between sufficient
alternatives (`require_exactly_one`), and arguments that conflict but are
jointly optional (`reject_together`).

`require_argument` returns the narrowed value rather than None. A void check
leaves every call site that feeds an optional into a required parameter
re-testing for None to satisfy mypy, which puts a second, unreachable message
beside the first -- and the two drift. Returning the value makes
`x = require_argument("x", x)` both the check and the narrowing, which is
also what keeps this change free of `type: ignore`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most callers of this package are programs -- a script, a pipeline stage, an
agent -- so the message is the only channel through which a caller can
correct itself. `"Service not recognized"` gives one nothing to try next.

Adopt the shared checks across the adapters and give the remaining
hand-written raises an action. Then, because a remedy that reads well can
still be unusable, every message was triggered for real and its remedy pasted
back and run against the live service. That found three ways a well-written
remedy still fails:

It can name something the caller cannot use. `datetime_input` was a private
helper's local, so correcting the argument the message named sent an
unrecognized parameter; `_format_api_dates` and `_validate_data_source` now
take the caller's spelling, so the subject is the argument actually passed.
`configure(Configuration(api_key=...))` was unrunnable twice over -- the name
is not bound unqualified, and `configure` is a context manager, so the bare
call the message printed applied nothing and the retry went out
unauthenticated, causing the very failure the message exists to prevent.
`pip install dataretrieval[nldi]` globs in zsh and installs nothing.

It can name values the service rejects. `query_waterdata` advertised
'ratings', which is not an NwisWeb program: the URL it builds answers 200
with an HTML error page. The bounding-box remedies omitted
`coordinate_format='decimal_degrees'`, without which the completed
four-corner box is refused the same way. `get_reference_table` rejected
'countries', a collection served beside 'counties'.

It can point somewhere useless. Defunct getters said "no replacement
available" where one exists, page-walk failures advised obtaining an API
token on hosts that honour none, and rdb's HTML fallback linked a docs
landing page with no status information instead of the decommission notice.

Tests assert the remedy rather than the sentence: pinning exact phrasing
freezes the wording and verifies nothing that matters to a caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both of these produced no error at all, which for an autonomous caller is
worse than a raise: an empty or truncated frame reads as a finding.

`get_features` told a caller supplying only `comid` that `navigation_mode`
was the missing argument. Supplying it built
`/comid/13294314/navigation/UM/None` -- the absent `data_source`
interpolated into the path as the literal string "None" -- and the service
answered 200 with an empty FeatureCollection, which `_features_to_gdf`
deliberately turns into an empty GeoDataFrame. Indistinguishable from a
navigation that genuinely has nothing on it. `data_source` is now required
wherever a navigation is built, which covers the `comid` and
`feature_source` origins alike, and the tail can no longer be None.

`get_nearest_continuous` told callers to include 'time' in `properties`. A
list that did -- `['time', 'value']` -- came back without
`monitoring_location_id`, so `_select_nearest_rows` fell back to a single
ungrouped batch and returned one row per target across all sites instead of
one per (target, site). Two sites became one row, silently. The getter now
injects both columns into a caller's `properties` rather than rejecting the
list: the caller asked for columns, not for a lecture about which ones this
getter needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_cql was the only Water Data getter without `max_rows`, and its `limit`
is the page size. So the obvious way to ask for a few rows -- `limit=5` --
instead paged the entire match five rows at a time. An agent probing the
getter that way spent ~400 requests of an hourly quota of 1000 in a single
call before the service refused it, and nothing warned it: `limit` was
documented only as "Page size, clamped server-side to 50,000", while the
sibling getters spell the trap out and point at `max_rows`.

The cap needed no new machinery. `get_ogc_data` already takes `max_rows`
and the cql_body branch already forwards it as `row_cap`; get_cql simply
never exposed the parameter. Measured live against a 10-row match:
`limit=3` fetches 4 pages, `limit=3, max_rows=3` fetches 1.

`limit`'s docstring now says what it does -- including that a small value
makes more requests, not fewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thodson-usgs
thodson-usgs force-pushed the docs/refresh-agents-context branch from 595a8ef to b0fd59d Compare August 20, 2026 18:36
thodson-usgs and others added 13 commits August 20, 2026 14:11
The suite is offline by default, and this test was the one exception:
triggering the missing-``data_source`` check through a ``feature_source``
origin validates that source first, which fetches the NLDI catalog. The
autouse fixture clears the catalog cache before every test, so the fetch was
a live request -- 0.29s, one real socket, and a CI failure whenever NLDI is
down, for an error that is entirely local.

Found by running the suite under a socket spy; it and the deliberate
localhost server in waterdata_chunking_test were the only two tests opening
connections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
``get_reference_table`` singularized the collection name three ways: the
counties/countries special case, a strip-trailing-s arm, and a fallback for
a collection that is not plural. All nineteen collections in
``METADATA_COLLECTIONS`` end in "s", and ``require_one_of`` rejects anything
outside that closed vocabulary before this runs, so the fallback could not
execute -- coverage found it as a permanently uncovered line.

``removesuffix`` collapses the two general arms into one that behaves
identically for all nineteen (verified) and stays correct if a singular
collection is ever added, which the deleted branch was there to guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage already ran in CI and reported a number nobody was accountable to.
Make it a ratchet alongside xenon, complexipy, and lint-imports: branch
coverage, `fail_under` set at the measured value in `[tool.coverage.report]`,
so it fails on regression rather than demanding tests of a change that added
none.

Branches rather than lines, because that is where this package's bugs live: a
dispatch arm routing to the wrong getter, an error path that never fires, a
fallback that quietly becomes the norm.

96% -> 98.97%, from 67 tests that pin behaviour the suite was not asserting:

- `get_record`'s service dispatch. Six near-identical arms, and every one
  forwards `sites` except `ratings`, which takes a scalar `site`. Nothing
  covered any of them.
- The 200-with-an-HTML-error-page path, whose message this PR rewrote and
  which no test reached.
- `get_features_by_data_source` -- an entire public getter with no test.
- The deliberate empty-frame-on-non-JSON contract in `_query_nldi`, pinned
  because it reads like an oversight and is easy to "fix" into a raise that
  would crash a legitimately empty navigation.
- `format_datetime`'s zone handling and its incomplete-date warning.
- Config resolution when the filesystem will not answer: no home directory,
  a deleted working directory, an unreadable file.
- The Windows home-variable precedence, which the memo has to agree with.

Excluded rather than faked: `_version.py` (generated at build time) and the
`ImportError`/`PackageNotFoundError` environment fallbacks, which the test
extras make unreachable by construction -- reaching them means unimporting a
module mid-run, which tests the import system rather than this package.

The threshold carries two decimals on purpose: 98.97% renders as "99%" and
would otherwise pass `fail_under = 99` on rounding alone. The remaining gap
is concentrated in async fan-out internals, the shaping dialect defaults, and
a few partial branches -- reachable, but each needs real scaffolding. Those
are the next rungs, not exclusions.

The blocking run is one Linux job; the OS matrix reports with
`--fail-under=0`, since its POSIX-only skips measure a smaller suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on this branch, all introduced by it.

`query_waterdata`'s docstring still advertised `'peaks' or 'ratings'` after
the service check narrowed to peaks-only, so the documented call
`query_waterdata("ratings", ...)` now raises. It points at `get_ratings`
instead. (`WATERDATA_SERVICES` keeps `'ratings'` on purpose -- `get_record`
still dispatches on it.)

`get_reference_table`'s docstring enumerates the vocabulary by hand and did
not gain `"countries"` when the collection did, so the prose denied a
collection the function serves. A test now pins that list against
`METADATA_COLLECTIONS`, since hand-maintained enumerations are exactly the
thing that drifts.

`allow_duration` read as a rejection switch but only chose wording inside
one message; `get_ratings` was safe solely because its own duration guard
runs first. The next caller passing `allow_duration=False` would have
accepted `time="P7D"` silently. Renamed to `advertise_duration`, and the
docstring now says in as many words that it does not enforce. Also documents
`name`, which was undocumented too.

On the coverage ratchet's margin: branch arcs can differ between
interpreters, and the threshold was measured on 3.12 while the blocking job
runs 3.14. Measured on both -- 98.97%, with identical statement and branch
counts -- so the threshold is verified against the interpreter that enforces
it. Recorded in the config. CONTRIBUTING now also says to use
`--fail-under=0` locally on Windows or without the nldi extra, where tests
skip and the local number falls under the gate through no fault of the
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three of the new messages named a move that was wrong for some of the
callers that can reach them.

`codes.states` is a shared leaf: `to_state` is a public conversion with
no `state` argument at all, and `apply_state` serves Water Data
(`state_name`/`state_code`) and NGWMN (`state_name` for sites, `state`
for providers). Hard-coding `state_name=`/`state_code=` in the table's
rejection told a `ngwmn.get_providers(state="Puerto Rico")` caller to
send a parameter that service does not have. The table now reports only
what it knows -- no row for a territory -- and `apply_state`, which does
know the endpoint's queryables, appends them.

`BaseMetadata.site_info` is the metadata class for NGWMN and NWDC as
well as Water Data, so pointing every caller at
`waterdata.get_monitoring_locations` misdirected two of the three.

`_get_features_request` required `data_source` before validating
`navigation_mode`, so a mistyped mode was answered with a message about
a different argument; the caller fixed that, re-ran, and only then heard
about the typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`apply_state` built its territory remedy from `(into, *reject)`. `into` is
the wire queryable the endpoint filters on, not necessarily a parameter the
getter accepts, so on NGWMN the message named an argument that does not
exist:

    ngwmn.get_sites(state="Puerto Rico")
    -> "... Pass state_name instead ..."
    ngwmn.get_sites(state_name="Puerto Rico")
    -> TypeError: unexpected keyword argument 'state_name'

and on `providers`, where the queryable *is* `state`, following the remedy
re-raised the identical error. Only `reject` proves a spelling reaches the
getter -- the mutual-exclusion guard is that proof -- so the remedy is now
drawn from `reject` alone, with `into` leading when it appears there too
(the Water Data message is unchanged). An endpoint with an empty `reject`
has no alternative to offer and appends nothing.

The NGWMN `state` docstrings said "State/territory filter"; territories have
never been accepted there, so they now say so.

Also documents that `get_nearest_continuous` adds `time` and
`monitoring_location_id` to a caller's `properties` list, since the returned
frame carries columns the caller did not request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four message defects found reviewing DOI-USGS#387, all of the shape the new
AGENTS.md rule names: following the remedy literally must produce a
working call.

nldi._validate_feature_source_comid checked the feature pair before the
origin conflict, so `get_features(comid=13294314, feature_id='X')` was
told "Pass both, e.g. feature_source='WQP'" -- and the corrected call
then raised "Provide exactly one of comid or feature_source". Two round
trips for one mistake, in both directions of the pair. A comid now
rejects either half up front, and both checks offer the same two ways
forward from one shared hint.

nwdc._resolve_locations closed its empty-value complaint with "(exactly
one of state, county, or huc must be given)" -- but `state=[]` did give
exactly one, so the parenthetical sent the caller to change a different
selector than the one at fault.

_nwdc_error_detail is annotated `str | None` and returned `body["detail"]`
unguarded; a validation envelope spells that as a list of error objects,
which would have been interpolated verbatim into the message. It also
appended a period to a detail that already ended in one.

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

The ratchet was bolted onto the `complexity` job, which has no `needs:` and
previously ran only fast structural checks. It installed the full test stack
and ran the whole suite there, so the suite executed 7x per PR and xenon,
complexipy and import-linter no longer failed fast.

It moves to the test matrix, which already runs that suite, and blocks on
every leg but Windows -- where POSIX-only tests skip and the number measures
a genuinely smaller suite. Phrased as "not Windows" rather than by naming one
leg, so editing the matrix cannot leave the gate silently unenforced.

That only works if every enforcing interpreter measures the same number, and
they did not: 3.10 read 98.95% against 3.14's 98.97%, because the `tomllib`
arm of the version-conditional import in `_toml_parser` is dead there while
the `tomli` arm it does take is already excluded. Version-conditional code is
now excluded outright -- one arm of it is dead on every interpreter by
construction. Measured 3693/20/970/28 = 98.97% identically on 3.10, 3.13 and
3.14.

The exclusion patterns are also anchored to the start of a statement. They
were matched anywhere on a line, so `except ImportError` named in a comment
or a string would have silently excluded whatever block followed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_validation` landed with the claim that it owns the wording for the checks
that recur, but only `nldi` adopted it. Every other site that should have had
a reason the module could not serve it, so each stayed hand-written -- which
is the drift the module exists to end, reintroduced in the commit that
created it. The module now serves them.

`error=`: a check raises the caller's exception class. `nwis` has answered a
malformed query with `TypeError` since long before this module existed, and a
deprecated module cannot start raising `ValueError` without breaking the
handlers written against it. The exception type is the caller's spelling in
the same sense the parameter name already was.

`remedy=` on `require_one_of`: a vocabulary narrower than the service's needs
both halves -- the options this function takes, and how to reach the rest.
There is no derived remedy to override here, so it is added rather than
substituted; that gap is why `get_cql` hand-wrote its collection check in the
same commit that added the rule against hand-writing.

`require_any_of`: "at least one filter" was written twice in `nwis` and had
no shape here.

Six messages unify as a result, and `sites=None` no longer counts as a major
filter -- it satisfied `key in kwargs` and reached the service as an empty
`sites=`, which is the wrong-answer shape the AGENTS.md rule names. The
`WATERDATA_SERVICES` constant now says why it is wider than what
`query_waterdata` reaches, rather than reading as a stale routing table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch changes rejection wording across nwis, adds get_cql(max_rows=),
and makes get_nearest_continuous append two columns to an explicit
properties list -- a returned-column-shape change that until now lived only
in a private helper's docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the constraint, drops the narration: measured coverage figures and the
interpreter that produced them, the paragraph naming which modules the
remaining gap sits in, and a comment describing what require_together does
next to the call. Where a comment survives, it says why the code is shaped
that way, in a line or two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 20-agent A/B run over this branch measured how many tokens an agent needs
to fix a bad call. Three results came out of the traces.

The most expensive task in the experiment -- 6.8k output tokens, 816k total,
and the one case the branch barely improved -- was a caller asking NGWMN for
Puerto Rico. Agents spent four to six tool calls spelunking through
``ogc/engine`` to hand-build a raw request, and succeeded: NGWMN answers
``state_name='Puerto Rico'`` with 36 monitoring locations. Water Data returns
Puerto Rico sites too, and legacy NWIS lists 1,148 for ``stateCd=PR``. The
table was blocking data all three services carry, so the fix is the table,
not the message. ``get_sites(state='Puerto Rico')`` now returns those 36 rows.

The ``query_waterdata`` service remedy pointed at ``nwis.get_ratings(site=...)``,
which is ``@_deprecated`` and keyed by a bare site number. An agent following
it paid for a DeprecationWarning and then a second discovery of the AGENCY-ID
form; one trial took five attempts. It now names
``waterdata.get_ratings(monitoring_location_id='USGS-01646500')``.

The two tasks the branch won most (-28%, -44%) had remedies carrying a
concrete value; the one it lost (+10%) named parameters only. The major-filter
remedies now carry example values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 traces: with the remedy naming waterdata.get_ratings, source reading
fell from 9/10 to 2/10 -- and both survivors opened waterdata/ratings.py for
the same reason, to learn what the call returns. Every trial in both arms
ended at ratings["USGS-01646500.exsa.rdb"], a key no caller can guess from a
function name. A remedy the caller has to follow with a source read has not
finished naming the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant