ADR 0010: Adapter-scoped settings
Status
Accepted, except for two clauses. Supersedes the “One flat set of setting names” and “Per-service overrides are deferred” clauses of ADR 0009: Layered configuration resolution; the rest of ADR 0009 stands, subject to what ADR 0011 supersedes there.
ADR 0011: Configuration profiles, scoped to one adapter supersedes decisions 5 and 8 below –
adapter schemas held centrally as TypedDict, and each adapter a named
keyword on configure(). Each adapter now declares a BaseConfiguration
subclass in the module that reads those settings, and configure() takes
instances of them positionally, which is what removes the adapter roster from
the call site. The spelling shown in decision 1 goes with decision 8. Decisions
2, 3, 4, 6 and 7 stand: the tiers, source-major precedence, package-wide
environment variables, the host-scoped key, and the adapter names.
Context
ADR 0009 resolved every setting through one flat namespace, on the premise that “a setting means the same thing to every service; services differ in the value they want, not the vocabulary.” Surveying the seven APIs this package retrieves from shows the premise is false. The settings themselves differ:
Adapter |
Host / path |
Fan-out |
Retryable statuses |
|
|---|---|---|---|---|
|
|
yes (OGC chunking) |
all 5xx + 429 |
yes, on 3 of its getters |
|
|
yes (OGC chunking) |
all 5xx + 429 |
– |
|
|
yes (fan-out) |
all 5xx + 429 |
yes |
|
|
no |
gateway only |
no |
|
|
no |
gateway only |
yes |
|
|
no |
gateway only |
no |
|
|
no |
fixed, no retry |
yes |
concurrency and parallel_chunks are meaningless for the four
single-shot adapters – there is nothing to fan out. ssl_check applies to
four adapters (waterdata, nwdc, nwis, wqp) and is currently a
per-call keyword outside the chain entirely; it reaches httpx’s verify,
verified by spying on the client. A flat namespace accepts
configure(streamstats={"parallel_chunks": 8}) without complaint, which is
the typo class ADR 0009 exists to catch.
The credential is a separate axis, and measurement settled it. Probing the live APIs with and without a key:
NGWMN and Water Data are served from the same host (
ngwmn.pyderives its base URL fromcredentials.WATERDATA_BASE_URL).Both return
200with no key, and both returnx-ratelimit-limit: 1000with one.Alternating authenticated calls decrement a single counter (997, 996, 996, 994, 993, 992), so the two adapters share one quota pool.
Water Data’s OpenAPI declares
ApiKeyHeader/ApiKeyQuery; NGWMN’s declares no security scheme at all across 34 paths – yet the gateway meters it regardless. Every response carriesvia: ... api-umbrella.
The key is therefore a credential of the gateway fronting the host, not of either adapter. It cannot meaningfully vary per adapter: two keys against one quota pool is not a state the gateway can be in.
Decision
Settings are scoped to the adapter, not the service, and not the host.
The configuration file gains one table per adapter, beside the existing top-level keys:
concurrency = 16 # every adapter [ngwmn] concurrency = 4 # this adapter only
configure()takes the same shape, so one block configures several adapters at once:with dataretrieval.configure(ngwmn={"concurrency": 4}, wqp={"retries": 2}): ...
Note
The file table stands; the
configure()spelling above is superseded by ADR 0011: Configuration profiles, scoped to one adapter along with decision 8. One block still configures several adapters at once, now asconfigure(NgwmnConfiguration(concurrency=4), WqpConfiguration(retries=2)).The top-level tier survives. An adapter table overrides it per key; it does not replace it. Every setting still has a package-wide spelling, and the shipped
API_USGS_*variables are package-wide by construction.retriesandstall_timeoutare additionally adapter-scopable, because a service that answers slowly or refuses often warrants its own budget without changing anyone else’s.progressis not: it describes the caller’s terminal, and there is one progress line per call, so scoping it per adapter could only produce a contradiction.Precedence stays source-major. Resolution walks block, then environment, then file, as ADR 0009 defines; within each source an adapter-scoped value outranks a top-level one. The environment therefore still outranks the file, so a stale adapter table cannot quietly beat a variable exported for one run.
Adapter-scoped settings get no environment variables. Every entry in
ENV_VARSstays package-wide, for the reasonparallel_chunksalready has none: an exported variable is inherited by every subprocess and invisible at the call site. Six adapters times four settings would be a namespace nobody could hold in mind.Each adapter’s schema is a
TypedDict. Its__annotations__are the schema – there is no second table to maintain,mypy --strictchecks literal dicts at call sites, and the file path validates against the same annotations. A key an adapter does not accept raisesConfigurationErrorat block entry, the way an unknown profile already does.Superseded by ADR 0011: Configuration profiles, scoped to one adapter. The schema is now a frozen dataclass owned by the adapter, for the same “the annotations are the schema” reason – what changed is where it lives. A
TypedDicthad to be declared centrally to annotate a central keyword, which put a Water Data setting’s definition in a module that knows nothing about Water Data.The API key stays host-scoped and is not an adapter setting.
credentialskeeps sole ownership of which host honors the key. There is no[ngwmn] api_key.Adapters are keyed by their service’s name, matching the module:
waterdata,ngwmn,nwdc,wqp,nldi,streamstats. The deprecatednwisis deliberately absent: its calls pinmax_retries=0, so a[nwis]table could only be reported as live and then ignored – the failure this decision exists to prevent.Each adapter is a named, typed parameter on
configure(), annotated with its ownTypedDict, so a type checker rejects a setting the adapter does not read before the code runs. A**unknowncatch-all remains, and exists to turn a misspelled setting into a message naming the settings –configure(concurrancy=8)would otherwise be a bareTypeError.Superseded by ADR 0011: Configuration profiles, scoped to one adapter.
configure()takes configuration objects positionally instead, so the adapter is named by the class rather than by a keyword. The type checking survives – a setting an adapter does not read is not a field of its class – and the catch-all is no longer needed for a misspelling, becauseWaterdataConfiguration(concurrancy=8)is already aTypeErrornaming the keyword that does not exist. What the change buys is thatconfigure()no longer enumerates the adapters at all: that enumeration was the roster this ADR left spelled in four places.
Consequences
A caller can be gentle with one adapter without throttling the rest – the requirement ADR 0009 deferred. Because NGWMN and Water Data share a quota pool, throttling NGWMN now measurably preserves quota for Water Data.
The schema stops being a separate mechanism. Choosing
TypedDictover a hand-maintained table removes the failure mode where a new adapter setting is added and the validation table is not, and over a dataclass per adapter it keeps the payload a plain mapping, so the file and block paths share one validator andconfigurationgrows no runtime classes. Superseded with decision 5: the classes exist, and live with their adapters rather than in the leaf.A configuration object is still refused, but on narrower grounds than ADR 0009 stated. That ADR rejected an object because it had no way to reach the call. A per-adapter payload type does not have that problem – the
ContextVarremains the delivery mechanism and the type is only the payload’s shape.TypedDictis chosen over a dataclass for the reason above, not because an object could not be delivered.Withdrawn by ADR 0011: Configuration profiles, scoped to one adapter, which took the remaining step. Narrowing the objection to a payload-shape preference is what left it open, and a dataclass turned out to buy the thing a mapping could not: an instance knows which adapter it targets, so the caller stops naming one and the roster stops being duplicated.
``show_configuration()`` grows a second section, not a matrix. It prints the top-level tier as today, then only those adapter overrides actually set. A seven-by-eight grid of mostly-inherited values would bury the answer to “what will this call use”.
The shared quota pool is not modelled.
[waterdata]and[ngwmn]read as independent dials but draw on one 1000/hour allowance. A host or gateway tier would express it; that is deferred until someone is confused by it, since the pool is a property of the credential, which is already host-scoped.``stall_timeout`` joins the chain.
API_USGS_STALL_TIMEOUTwas read directly fromos.environ, so it could not be set by a block or the file and never appeared inshow_configuration()– a gap in ADR 0009’s own claim that every setting resolves through one chain. It is package-wide by default and adapter-scopable.dataretrieval/transport/env.pyexisted only to parse it and is deleted, soconfigurationis now the only module in the package that readsos.environfor a setting.``ssl_check`` stays a per-call argument and does not become a setting. It is a defaulted keyword on 23 shipped getters across four adapters –
wqp(9),nwis(10),waterdata(3) andnwdc(1) – and it does reachhttpx’sverify. It was added in 2023 to what were then the only modules; the OGC getters arrived later and never adopted it, so its distribution records the package’s history rather than a boundary.Three reasons not to promote it. It disables certificate verification, so as a per-call keyword it is a visible, scoped decision, while a config-file key or environment variable would make a security downgrade process-wide and invisible at the call site – the opposite of the direction this chain narrows everything else. It does not respect adapter boundaries: within
waterdatait applies only to the getters that bypass the OGC engine, so[waterdata] ssl_checkwould be honored by three getters and silently ignored by the rest, exactly the shape this ADR refuses elsewhere. And the need it serves is already met better: the legitimate case is a TLS-intercepting corporate proxy, andhttpxnatively honorsSSL_CERT_FILEandSSL_CERT_DIRon both its sync and async clients – so that mechanism already covers every getter, including the OGC ones that have nossl_check, and it trusts the corporate CA rather than trusting nothing. Thebooltype cannot even carry a CA bundle path, which is the value a caller actually wants.The configuration guide documents
SSL_CERT_FILEfor that case. Whetherssl_checkshould be deprecated outright is a public-API question left to its own change.tests/configuration_test.pycovers adapter-table resolution, top-level inheritance per setting, source-major precedence (the environment still outranks an adapter table), an adapter block outranking a package-wide one, and rejection of a setting an adapter does not read – from both the file andconfigure().test_api_key_is_never_adapter_scopedasserts no adapter configuration acceptsapi_key.test_adapter_roster_names_real_modules_that_register_themselvesimports every name in the roster, so a renamed adapter cannot leave a configuration pointing at nothing.lint-importscontinues to placeconfigurationbetweencredentialsandexceptions.
The two entries covering decision 8’s **adapters catch-all
(test_a_misspelled_setting_is_not_taken_for_an_adapter) and the central
TypedDict registry (test_adapter_schema_names_a_real_module) went with
the clauses ADR 0011 superseded; the checks they stood for are named above in
their current form.
Notes
Supersedes two clauses of ADR 0009: Layered configuration resolution; the chain, the
ContextVardelivery, and the leaf constraint are unchanged.Live-API measurements behind the credential decision were taken 2026-08-11 against
api.waterdata.usgs.govandapi.water.usgs.gov.The
waterusemodule is renamednwdcunder separate cover; the service names itself “National Water Availability Assessment Data Companion” and serves ten models, only five of which are water use.