Skip to content

fix: one status request per polling loop, and handle HTTP 429 - #37

Open
shejnowicz wants to merge 3 commits into
mjilesen:masterfrom
shejnowicz:fix/rate-limit-upstream
Open

shejnowicz wants to merge 3 commits into
mjilesen:masterfrom
shejnowicz:fix/rate-limit-upstream

Conversation

@shejnowicz

Copy link
Copy Markdown

Starting a zone returned HTTP 429 from the Hydrawise cloud, and only restarting the app cleared it. Investigating that turned up three separate causes. Each is fixed here, with tests.

1. The app spends one request per ZONE per loop, not one per loop

Zone.updateZoneStatus carries the comment "batch to make sure that data from Hydrawise is fetched only once per loop", but the guard cannot deliver it:

if (batchID != this.lastBatchID) {
  this.data = await this.doRequest(url, params);
  this.lastBatchID = batchID;   // assigned only AFTER the await
}

app.js starts every device's update in parallel and awaits them with Promise.allSettled, so each device evaluates the guard before the first response lands, sees the marker unchanged, and issues its own request. On an account with six zones that is six simultaneous calls a minute — which is what tripped the rate limit.

One in-flight request is now shared per controller per loop. Keying by controller + API key rather than one shared this.data also fixes a latent bug for multi-controller accounts, where every zone previously read whichever controller answered last.

2. stopPolling could not cancel the loop it started

startPolling creates the timer with this.homey.setInterval, but stopPolling cleared it with the global clearTimeout instead of this.homey.clearInterval. startPolling runs on app init, on a pollingEnabled change, on a pollingInterval change and from both flow cards, so a missed cancel leaves the previous loop running alongside the new one. That is why restarting the app cleared the 429 — the restart killed the accumulated timers.

3. The legacy path had no 429 handling at all

lib/httpService.js is a bare axios wrapper whose interceptor only rethrows, so a 429 from statusschedule.php or setzone.php reached the user raw and the zone device was marked unavailable. Legacy calls now pass through one shared RateLimit gate honouring Retry-After (floored at 60 s), so a penalty incurred while polling also holds back zone commands — they share the account's quota, so they must share the penalty — and the caller gets rate_limited:<seconds> instead of a stack trace.

Also

Removes a stray sure; at the top of updateZoneStatus's catch block. Referencing that undefined identifier throws a ReferenceError, so setUnavailable(ex) never ran and the real error was replaced by the reference failure.

Tests

Six new cases in test/zone-ratelimit.test.js (node --test, no new dependencies): six parallel zones collapse to one call; a new loop refetches; separate controllers stay separate; a failed batch is not reused by the next loop; a 429 blocks further calls during the cool-off; and Retry-After parsing floors both the seconds and HTTP-date forms. The batching test was verified to fail against the current code before the fix.

No version bump — left to the maintainer, matching how releases are cut in this repo. homey app validate --level publish passes.

Starting a zone returned HTTP 429 (Hunter rate-limits the cloud per
account) and only an app restart cleared it. Three separate causes, each
fixed here.

- updateZoneStatus claimed to fetch "only once per loop", but app.js starts
  every device's update in parallel and the batch marker was assigned only
  AFTER the await — so each of the six zone devices issued its own request.
  One in-flight request is now shared per controller per loop. Keying by
  controller+API key rather than a single shared field also fixes a latent
  bug for multi-controller accounts, where every zone read whichever
  controller answered last.
- stopPolling cleared a this.homey.setInterval handle with the GLOBAL
  clearTimeout. startPolling runs on app init, on either settings change and
  from two flow cards, so a missed cancel leaves the previous loop running —
  which is why restarting the app fixed the 429.
- The legacy statusschedule/setzone path had no 429 handling at all: the
  bare axios wrapper rethrew, the raw error reached the user and the zone
  device went unavailable. It now shares one RateLimit gate with a
  Retry-After cool-off (floored at 60s), so a penalty triggered by polling
  also holds back commands, and the user gets "rate_limited:<seconds>"
  instead of a stack trace. The GraphQL sensor client already did this.

Also removes a stray `sure;` in updateZoneStatus's catch block: referencing
that undefined identifier threw a ReferenceError, so setUnavailable never
ran and the real error was replaced by the reference failure.

Tests: six parallel zones collapse to one call (verified failing without
the fix), a new loop refetches, separate controllers stay separate, a
failed batch is not reused, a 429 blocks further calls during the cool-off,
and the Retry-After parsing floors both header shapes.
Await capability updates before clearing an unavailable state after transient DNS or API errors. Cover all six zones, missing relays, and asynchronous writes. Refs shejnowicz/vishome#218.
fix: recover zone availability after transient API failures
@shejnowicz

Copy link
Copy Markdown
Author

Heads-up on scope: this branch gained a second, related fix — commits 3a6f1f5 + merge 17c6c36.

Zone availability now recovers after transient API failures. Previously a DNS/network/API error marked every affected zone unavailable via setUnavailable(ex), but no code path ever called setAvailable() again — the zones stayed dead until an app restart even though subsequent polls succeeded. Now a successful status poll restores availability, strictly fail-closed: only after the zone's relay was actually found in the response AND all five capability writes resolved. A missing relay or a failed write keeps the zone unavailable, and recovery never issues watering commands (regression test asserts the recovery poll hits only statusschedule.php).

Also awaits the previously fire-and-forget setUnavailable calls, which silently produced unhandled promise rejections in the catch paths.

Covered by 4 new node tests (six-zone recovery, missing relay, write-ordering, failed-write fail-closed) on top of the existing 6; homey app validate --level publish passes. Both fixes have been running together on my own Homey Pro against live Hydrawise hardware.

Happy to split this into a separate PR if you prefer them reviewed independently — they stack cleanly.

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