Skip to content

Retry transient server errors on setup (fixes #392) - #393

Merged
sveinse merged 12 commits into
custom-components:masterfrom
rhammen:fix/issue-392-retry-transient
Jul 25, 2026
Merged

Retry transient server errors on setup (fixes #392)#393
sveinse merged 12 commits into
custom-components:masterfrom
rhammen:fix/issue-392-retry-transient

Conversation

@rhammen

@rhammen rhammen commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #392. Closes #360.

Part A (retrying transient 5xx for the token request) is exactly what #360 ("Retry token on 503 responses") asked for, so this PR resolves both.

Built on top of #391 (Add offline unit tests for the Zaptec API client) — the tests here reuse its FakeSession / _make_zaptec harness.

The bug

On a fresh install (e.g. a dev-container), adding a Zaptec account intermittently fails setup with a 503 from the token endpoint, and only a full Home Assistant restart recovers:

RequestError: POST request to https://api.zaptec.com/oauth/token failed with status 503
ConfigEntryError: ... 503 Service Temporarily Unavailable

Reported by @sveinse in #392, who correctly suspected a race: setup issues a second /oauth/token POST within ~200 ms of the first, and the server occasionally answers that one with a transient 503.

Root cause

  1. No retry on transient 5xx for the token request. _refresh_token branched only on 200 (return) and 400 (auth error); every other status — including 503 — fell straight through to raise RequestError(...) on the first response. The retry loop in _request_worker only re-looped on TimeoutError / ClientConnectionError, never on an HTTP status.
  2. A transient error mapped to a permanent setup failure. In async_setup_entry, a RequestError (subclass of ZaptecApiError) became ConfigEntryError, which HA treats as permanent (no auto-retry). Only timeout/connection errors mapped to ConfigEntryNotReady. That's why only a restart recovered.

The fix

A — Retry transient statuses in _request_worker (zaptec/api.py, zaptec/const.py)

  • New RETRYABLE_HTTP_STATUSES = frozenset({HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.BAD_GATEWAY, HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.GATEWAY_TIMEOUT}).
  • The worker now retries those statuses for all methods using the existing exponential backoff, honoring a Retry-After header 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 normal RequestError(status). This fixes the token POST and every request() call from one place.
  • Deliberately not including 500: Zaptec returns 500 in various application-level cases, so it keeps its current behavior (retried only for GET). 429/502/503/504 are 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)

  • Extracted _config_entry_error(), which maps auth failures → ConfigEntryAuthFailed, and connection/timeout errors and transient 429/502/503/504ConfigEntryNotReady (HA auto-retries setup). All other API errors remain permanent ConfigEntryError.

Testing

Offline unit tests (built on #391's harness, no network/credentials needed):

  • Per-status retry-then-success for 429/502/503/504, plus a transient 503 retried on a POST.
  • Persistent 503 retried to exhaustion → RequestError with error_code == 503 (for both request() and the token endpoint).
  • Retry-After header 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 the 500 path is unchanged. Full offline suite green locally (SKIP_ZAPTEC_API_TEST=true), ruff format/ruff check clean.

🤖 Generated with Claude Code

rhammen and others added 4 commits July 8, 2026 20:19
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>
@sveinse sveinse added this to the v0.8.8 milestone Jul 9, 2026
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>
Comment thread custom_components/zaptec/zaptec/const.py Outdated
Comment thread custom_components/zaptec/zaptec/api.py Outdated
Comment thread docs/superpowers/specs/2026-07-09-transient-503-retry-design.md Outdated
@sveinse

sveinse commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Please sync this project with the latest version version in master.

@sveinse

sveinse commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

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.

rhammen added 4 commits July 24, 2026 22:27
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.
@rhammen

rhammen commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

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 docs/superpowers/specs/... design-doc question — happy to hear more input on whether to keep this pattern going forward.

Comment thread custom_components/zaptec/zaptec/api.py Outdated
Comment thread custom_components/zaptec/zaptec/const.py
Comment thread custom_components/zaptec/__init__.py Outdated
…comments

Avoids comments drifting out of sync with the actual set of retried
statuses, per review feedback on PR custom-components#393.
@sveinse

sveinse commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Awesome. I'm happy with this PR given that we remove the docs/superpowers/ document.

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.

@sveinse sveinse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This look good. Thank you @rhammen

@sveinse

sveinse commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

@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.

@rhammen

rhammen commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Proposed squash-merge commit message:

Retry transient server errors instead of failing setup permanently (#392)

Transient errors from the Zaptec API (429/502/503/504, or connection/
timeout errors) previously caused Home Assistant to treat the
integration as permanently failed during setup, requiring manual
intervention. Add backoff-based retries for these infrastructure-level
errors, and map any that still occur during setup to
ConfigEntryNotReady so Home Assistant retries automatically instead.

Fixes #392.

@sveinse
sveinse merged commit d199af3 into custom-components:master Jul 25, 2026
7 checks passed
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.

Setting up Zaptec integration fails Retry token on 503 responses

3 participants