committed 09:08PM - 16 Jul 26 UTC
* feat(aemet): add AEMET observation provider
Adds a new provider for Spain's A…EMET OpenData API, covering daily,
monthly, and annual climatological values plus real-time (convencional)
hourly observations, with API-key auth and rate-limit-aware retries via
stamina.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): redact api_key from log messages, fix restapi coverage test
CodeQL flagged clear-text logging of the AEMET api_key (embedded in
request URLs and logged verbatim on failures/rate-limit warnings).
Also updates test_restapi.py::test_coverage, which hardcoded the
provider list and didn't yet include aemet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): send api_key as a request header instead of a URL query param
The previous fix redacted api_key from log messages, but download_file()
itself still logged the raw key-bearing URL internally (log.info), so the
key could still leak into logs outside this module's own warnings.
Confirmed AEMET accepts api_key via an "api_key" header (a wrong key
sent this way gets the same JWT-validation error as the query-param
form), matching how metno_frost already sends its credentials via
headers rather than embedding them in URLs. With that, the key never
touches a URL that gets constructed or logged, so the redact helper
is no longer needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): broaden retry to cover transient network failures, not just 429
CI showed AEMET intermittently dropping connections outright (TLS
handshake failures, timeouts), which download_file()'s own short
built-in retry didn't clear and which previously fell straight
through as a lost chunk. The rate-limit retry loop now also retries
those (status in {408, 500, 503}), using an exponential backoff that
starts short and grows toward the ~60s a 429 needs, instead of a
fixed 60s wait for 429 only. Non-retryable failures (e.g. 404) are
still returned immediately.
Also documents the resulting behavior (calls can take noticeably
longer under rate limiting or connectivity issues) in the provider
docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): retry on any failure except definitively bad requests
The latest CI run showed a chunk failing without the retry loop even
firing -- its status wasn't in the {429, 408, 500, 503} allow-list.
Live probing confirmed AEMET's failure modes are too varied to
enumerate (timeouts, hangs, and failures on the second-stage "datos"
URL all surfaced with different statuses across a handful of runs).
Flipped to a deny-list: retry everything except statuses that mean
the request itself is invalid and retrying can't help (400/401/403/404).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): address Copilot review findings
- Annual resolution no longer declares "humidity" as a valid parameter
-- AEMET's annual aggregate never includes that field at all (unlike
monthly), so it was advertising a parameter that could never return
data. Split into a separate _ANNUAL_PARAMETERS list.
- Move the AEMET-credentials skip from a module-level pytestmark to a
per-test decorator on just the @pytest.mark.remote tests, so the
local unit tests (chunking, retry behavior) still run without
WD_AUTH__AEMET set (e.g. in forks without access to the CI secret).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): reduce retry budget, xfail live tests, add changelog entry
Live AEMET outages have repeatedly outlasted even a generous retry
budget across several CI runs, so a long retry (4 attempts, up to 60s
backoff) was mostly wasted time rather than a real reliability win.
Reduced to 2 short-backoff attempts (up to 15s), enough to smooth over
brief blips without making a single failing call hang for minutes.
The 5 live/remote AEMET tests are now xfail(strict=False), matching
the existing ECCC precedent in this test suite for flaky third-party
APIs -- a failure no longer blocks CI/merges on AEMET's own
availability, while a pass is still recorded when AEMET is up.
Verified live: 9 passed, 1 xfailed, 4 xpassed, exit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(aemet): normalize non-UTC start/end date before formatting requests
TimeseriesRequest.convert_timestamps() only tags naive datetimes as
UTC -- it never converts an already-tz-aware non-UTC datetime. Since
_collect_daily/_collect_monthly_or_annual formatted chunk_start/
chunk_end with a hardcoded "UTC" suffix, a caller passing e.g. a
Europe/Madrid-tz datetime would silently have AEMET query the wrong
time window (off by the local UTC offset). Both now explicitly
convert to UTC first via ZoneInfo("UTC"), matching the convention
already used elsewhere in this codebase (request.py, test file).
Adds a regression test using a mocked _fetch_datos to assert the
actual request URL reflects the correctly-converted UTC timestamps
for a Madrid-tz input, without needing live credentials or network
access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>