Retry transient server errors on setup (fixes #392) - #393
Conversation
api.py previously had only a single integration test that requires live login, leaving the core logic effectively uncovered offline. This adds a fake aiohttp ClientSession and exercises the pure logic and request machinery without credentials or network: - request() status handling: 200 JSON, 204 bytes, invalid JSON, error status codes, 500 GET retry-to-exhaustion vs 500 POST immediate raise, 401 -> token refresh -> retry - _request_worker retry/backoff: connection errors and timeouts retried then surfaced as RequestConnectionError / RequestTimeoutError - state_to_attrs: keydict mapping, Value/ValueAsString precedence, missing key/value skipping, excludes, duplicate-last-wins - set_attributes: ATTR_TYPES conversion, snake_case keys, conversion-failure fallback, update-in-place - is_command_valid: all resume/stop branches (incl. a characterization test for the int(None) issue flagged for the Phase 3 correctness cleanup) - stream_update routing: matching charger, unknown/missing/zero-guid ids - Zaptec mapping + poll dispatch: register/contains/qual_id, poll dispatch and unknown-object error Tests are self-contained (no live-constants fixture) so they run under SKIP_ZAPTEC_API_TEST and in CI. Lint-clean under the repo's ruff config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follows up the initial offline suite with the cheap, high-value cases, raising api.py statement coverage from ~50% to ~66%: - command(): named id, numeric id, authorize_charge alias, unknown-command error - charger settings wrappers: set_settings (valid + unknown-key), authorize_ charge, set_permanent_cable_lock, set_hmi_brightness (URL + payload) - installation current setters: set_limit_current (availableCurrent, missing-arg, partial-phase, out-of-range) and set_three_to_one_phase_switch_current (valid + out-of-range) - Charger.poll_info (happy, 403 -> charger-list fallback, non-403 re-raise) and poll_state (happy, 403 ignored) - Installation.poll_info SupportGroup logo stripping - login()/_refresh_token: token stored and sent on later requests; 400 -> AuthenticationError - small accessors/lifecycle: is_charging, model/model_prefix, Zaptec objects/installations/chargers/iter/len, async context manager Payload validation is bypassed in the poll tests (it has its own test module). build()/streaming/poll_firmware remain uncovered and are better tested alongside the Phase 2 typed-model work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The token/OAuth request and every other API call previously raised on the first transient HTTP error: the retry loop in _request_worker only re-tried TimeoutError/ClientConnectionError, and _refresh_token raised immediately on any non-200/400 status. A single 503 from api.zaptec.com on the token POST was therefore fatal (issue custom-components#392). Treat 429/502/503/504 as retryable in _request_worker for all methods, using the existing exponential backoff and honoring a Retry-After header when present. On the final attempt the response is still yielded so the caller raises its normal RequestError(status). This covers both _refresh_token and request() from one place. The existing 500-on-GET-only behavior is unchanged, so non-idempotent POST/PUT are never double-executed on a 500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
) A transient RequestError during setup login was mapped to ConfigEntryError, which Home Assistant treats as permanent (no auto-retry) -- so a 503 on the token endpoint required a full HA restart to recover. Extract _config_entry_error(), which maps authentication failures to ConfigEntryAuthFailed and connection/timeout errors plus transient server statuses (429/502/503/504) to ConfigEntryNotReady, letting HA retry setup automatically. All other API errors remain permanent ConfigEntryError. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Satisfies the new enforcing ruff check CI step (excludes api.py): D209 in _config_entry_error's docstring and PLR2004 magic values (2, 2.0) in the transient-retry offline tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Please sync this project with the latest version version in master. |
|
Some tips: It is useful (and necessary) to get information that the PR is stacked and builds on previous PRs. But there is no need to explain in the description on how to merge the staged PRs. Please keep the description to the point what they fix. All PRs must apply cleanly and only contain the relevant changes before merge. We use squash merge, and all previous PRs which this builds on will not share any history. Thus, master must be merged into the PR branch right before its ready to be merged into master (this happens after all its dependent PRs have been merged). When the final merge has been done, the diff is clean and will only contain the diff. No commits from the PR branch will be preserved. |
Addresses review feedback on PR custom-components#393: avoids magic numbers and typo risk compared to raw integer literals.
Addresses review feedback on PR custom-components#393: a short Retry-After from a transient response could otherwise reset the exponential backoff progression, risking a retry storm against a still-struggling server. Retry-After can now only extend the wait, not shrink it.
…ry-transient # Conflicts: # tests/zaptec/test_api.py
Caught by ruff check after merging master's lint cleanup baseline.
|
Pushed an update addressing the review feedback:
Also trimmed the PR description down to just what this fixes, per the note above. Still open: the |
…comments Avoids comments drifting out of sync with the actual set of retried statuses, per review feedback on PR custom-components#393.
|
Awesome. I'm happy with this PR given that we remove the Since many PRs are staggered on each other, it doesn't make sense to review the other PRs just yet. We'll have to do them one by one which will take some time with some back and forth. |
Per discussion on PR custom-components#393, docs/superpowers/specs/... loses most of its value outside the PR context; keep it out of the repo for now.
|
@rhammen could you be so kind to help with one thing please: The squash commit message will be extremely long with overwhelming amount of text from all the commits. Can you propose (use Claude if you prefer) to make a short, to-the-point, single commit message that sums up everything in this PR, please? It should contain what the intent of the PR is in short concise language, not a verbose list of all that's changed (it is possible to read the diff). Please post the proposal as a comment. |
|
Proposed squash-merge commit message: |
Fixes #392. Closes #360.
Built on top of #391 (
Add offline unit tests for the Zaptec API client) — the tests here reuse itsFakeSession/_make_zaptecharness.The bug
On a fresh install (e.g. a dev-container), adding a Zaptec account intermittently fails setup with a
503from the token endpoint, and only a full Home Assistant restart recovers:Reported by @sveinse in #392, who correctly suspected a race: setup issues a second
/oauth/tokenPOST within ~200 ms of the first, and the server occasionally answers that one with a transient503.Root cause
_refresh_tokenbranched only on200(return) and400(auth error); every other status — including503— fell straight through toraise RequestError(...)on the first response. The retry loop in_request_workeronly re-looped onTimeoutError/ClientConnectionError, never on an HTTP status.async_setup_entry, aRequestError(subclass ofZaptecApiError) becameConfigEntryError, which HA treats as permanent (no auto-retry). Only timeout/connection errors mapped toConfigEntryNotReady. That's why only a restart recovered.The fix
A — Retry transient statuses in
_request_worker(zaptec/api.py,zaptec/const.py)RETRYABLE_HTTP_STATUSES = frozenset({HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.BAD_GATEWAY, HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.GATEWAY_TIMEOUT}).Retry-Afterheader when present (as a floor on top of the computed backoff, never shortening it). On the final attempt it still yields the response so the caller raises its normalRequestError(status). This fixes the token POST and everyrequest()call from one place.500: Zaptec returns500in various application-level cases, so it keeps its current behavior (retried only for GET).429/502/503/504are infrastructure-level "try again shortly" errors where the request typically never reached the app, so retrying is safe even for POST/PUT — no risk of double-executing a non-idempotent command.B — Map transient errors to
ConfigEntryNotReady(__init__.py)_config_entry_error(), which maps auth failures →ConfigEntryAuthFailed, and connection/timeout errors and transient429/502/503/504→ConfigEntryNotReady(HA auto-retries setup). All other API errors remain permanentConfigEntryError.Testing
Offline unit tests (built on #391's harness, no network/credentials needed):
429/502/503/504, plus a transient503retried on a POST.503retried to exhaustion →RequestErrorwitherror_code == 503(for bothrequest()and the token endpoint).Retry-Afterheader honored as a floor on the backoff delay._config_entry_error()mapping table (auth / timeout / connection /503/429/403/404).Existing regression tests (
500-on-POST not retried,500-on-GET retried) continue to pass, confirming the500path is unchanged. Full offline suite green locally (SKIP_ZAPTEC_API_TEST=true),ruff format/ruff checkclean.🤖 Generated with Claude Code