Repository navigation
feat: reconnect the live stream with bounded backoff - #23
Merged
Merged
Conversation
`powerctl live` now survives a transient network failure instead of exiting, so it can be left running as a monitor. Subscribe stays a single-attempt primitive; SubscribeWithReconnect layers the retry above it. Backoff starts at 5s and doubles to a 5-minute ceiling with equal jitter, and no more than 10 connections are opened in a rolling hour -- Tibber allows 20, and a stream that keeps failing must not spend the allowance a manual restart needs. Errors are classified before retrying. A wrong answer here is expensive in both directions: dropping a recoverable stream, or burning the hourly budget on a request that can never succeed. Read errors and abnormal closes retry; close code 4403, a refused handshake, an unknown home ID and other GraphQL errors are fatal and exit 1. Fatal errors are tagged where they are raised rather than matched on message text later. Adds --no-reconnect for the previous behaviour. Reconnect notices go to stderr, keeping stdout a clean data channel for --format json. Also fixes an adjacent bug the tests exposed: on Ctrl+C the deferred close handshake waited up to five seconds for a reply the server may never send. Closes #22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request
Related Issue
Closes #22
Goal
powerctl liveshould survive a transient network blip instead of exiting, so it can be left running as a monitor. Follows the plan in #22, including the settled decision there: reconnect on by default,--no-reconnectto opt out.One deviation from the plan, called out below: a small adjacent fix to the Ctrl+C path that the tests exposed.
Changes Made
internal/api/reconnect.go(new) —ReconnectPolicy, the retry loop, the error taxonomy.Subscribestays a single-attempt primitive;SubscribeWithReconnectlayers retry above it, so the connection code stays testable without a loop threaded through it.ErrReconnectBudget.internal/api/websocket.go— fatal errors are tagged at the point they're raised (markFatal) rather than matched on message text later. Covers a refused handshake (401/403),connection_errorin place ofconnection_ack, GraphQL error payloads, and handler errors.internal/commands/live.go—--no-reconnect; reconnect notices to stderr.Retryable vs fatal
Getting this wrong is expensive in both directions — dropping a recoverable stream, or burning the hourly budget on a request that can never succeed.
4403 Invalid tokenconnection_errorinstead ofconnection_ackctxcancelled by SIGINT/SIGTERMThe ack timeout is why cancellation is detected by checking
ctxin the loop rather than by matchingcontext.DeadlineExceededon the error — that deadline is internal, and a timed-out ack is exactly the transient case worth retrying.Adjacent fix, outside the issue's scope
The cancellation test sat at exactly 5.00s, which turned out to be a real bug rather than slow testing: on Ctrl+C the deferred close handshake waits up to five seconds for a reply the server may never send.
Subscribenow drops the socket withCloseNowwhen the context is already cancelled. Pre-existing, two lines, and it directly undermines the headline behaviour here (a monitor you leave running and then interrupt) — but say the word and I'll split it out.Type of Change
On "non-breaking": the default changes.
livepreviously exited 1 on the first stream error; it now retries transient ones. Anything scripted around that prompt non-zero exit needs--no-reconnect. This was the open question in #22 and was settled there in favour of default-on.Tasks
Mirroring #22:
--no-reconnectflagREADME.md/ARCHITECTURE.mdupdatedLeft as scoped out in the issue: heartbeat/ping, backfilling measurements missed during a gap, any change to
consumptionor the fail-fast HTTP client.Testing
go test ./...passes locallyTwo layers.
reconnect_test.godrives the loop on a faked clock — sleeping advances time, so the rolling-hour budget is exercised in microseconds.livestream_test.goruns the whole path over a real socket: anhttptestserver speakinggraphql-transport-ws, dropping the connection mid-stream (CloseNow) and asserting the client reconnects and keeps delivering measurements.LiveClientgained one unexportedendpointfield so tests can point it at that server;NewLiveClientstill setsWebSocketEndpoint.Tests were checked for teeth. Three mutations of the implementation, each caught:
retryableignores the fatal classificationThe budget mutation originally hung rather than failing — an uncapped loop spins forever on a faked clock — so the always-failing attempt helper now aborts past a bound. With that, the same mutation fails in 3ms instead of stalling until the
go testtimeout.Verified against the real Tibber endpoint, which is the part a mock can't prove — that
4403is genuinely what the server sends:Fatal, immediate, no retry loop, and nothing on stdout.
Checklist
gofmt -s -l .reports nothinggo vet ./...is clean--format jsonoutput remains stable — reconnect notices go to stderr precisely to keep the stdout contract intactAI Assistance
Written with Claude Code. Reviewed, tested and mutation-checked as described above.
Additional Notes
Two things worth a reviewer's attention:
--no-reconnectsince that's all Reconnect the live stream with bounded backoff #22 called for.⚡ Built on gilfoyle with Claude Code