Skip to content

Commit 9148c20

Browse files
Merge pull request #23 from KristofferRisa/feat-live-reconnect
feat: reconnect the live stream with bounded backoff
2 parents ee471ad + 3809f77 commit 9148c20

7 files changed

Lines changed: 1067 additions & 19 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ powerctl-cli/
2222
│ │ ├── client_test.go
2323
│ │ ├── queries.go # GraphQL query definitions
2424
│ │ ├── websocket.go # WebSocket for live streaming
25-
│ │ └── websocket_test.go
25+
│ │ ├── websocket_test.go
26+
│ │ ├── reconnect.go # Backoff, hourly budget, error taxonomy
27+
│ │ ├── reconnect_test.go
28+
│ │ └── livestream_test.go # End-to-end against a local WebSocket server
2629
│ ├── commands/
2730
│ │ ├── root.go # Root command, global flags
2831
│ │ ├── config.go # `powerctl config` - setup wizard
@@ -94,12 +97,43 @@ format: "markdown" # Default output format
9497

9598
- Protocol: `graphql-transport-ws`
9699
- Requires `User-Agent: powerctl-cli/1.0` — Tibber rejects the default Go client
97-
- Reconnection: none. A read or parse error ends the stream and exits 1
98100
- Heartbeat: none. The connection lives as long as the server keeps it open
99-
- Graceful shutdown on SIGINT/SIGTERM
101+
- Graceful shutdown on SIGINT/SIGTERM, including mid-backoff
100102
- A GraphQL error payload or a null `liveMeasurement` becomes an error rather
101103
than a nil measurement handed to the formatter (see #13)
102104

105+
#### Reconnection (`reconnect.go`)
106+
107+
`Subscribe` stays a single-attempt primitive; `SubscribeWithReconnect` layers
108+
the retry on top, so the connection code stays testable without a retry loop
109+
running through it.
110+
111+
- Backoff: 5s, doubling to a 5-minute ceiling, with equal jitter (half the delay
112+
fixed, half random)
113+
- Budget: at most 10 connection attempts per rolling hour. Tibber allows 20, and
114+
a failing stream must not spend the allowance a manual restart needs. Hitting
115+
the cap ends the stream with `ErrReconnectBudget`
116+
- Reset: a connection that delivered data and stayed up for a minute returns the
117+
backoff to 5s
118+
- `--no-reconnect` skips the layer entirely and calls `Subscribe` directly
119+
120+
Whether an error is retried is the substance of it, since a wrong answer either
121+
drops a recoverable stream or burns the hourly budget on a request that can
122+
never succeed:
123+
124+
| Condition | Behaviour |
125+
|-----------|-----------|
126+
| Read error, abnormal or server-initiated close | Retry |
127+
| Handshake refused with HTTP 401/403 | Fatal, exit 1 |
128+
| Close code `4403 Invalid token` | Fatal, exit 1 |
129+
| `connection_error` in place of `connection_ack` | Fatal, exit 1 |
130+
| Unknown home ID, other GraphQL errors (see #13) | Fatal, exit 1 |
131+
| Ack timeout (10s, internal deadline) | Retry |
132+
| `ctx` cancelled by SIGINT/SIGTERM | Clean exit 0 |
133+
134+
Fatal errors are tagged at the point they are raised (`markFatal`) rather than
135+
matched on message text later.
136+
103137
### Commands (`internal/commands/`)
104138

105139
| Command | Input | Output | Exit Codes |
@@ -110,7 +144,7 @@ format: "markdown" # Default output format
110144
| `home` | - | Home info | 0=OK, 1=Error |
111145
| `prices` | - | Price list | 0=OK, 1=Error |
112146
| `consumption`| `--resolution`, `--last`, `--home-id` | Consumption history | 0=OK, 1=Error |
113-
| `live` | `--home-id` | Stream | 0=Clean exit, 1=Error |
147+
| `live` | `--home-id`, `--no-reconnect` | Stream | 0=Clean exit, 1=Error |
114148
| `version` | - | Version, commit, build date | 0=OK |
115149

116150
### Output Formatters (`internal/output/`)
@@ -180,6 +214,7 @@ Authorization: Bearer <token>
180214
| Category | Strategy |
181215
|----------|----------|
182216
| Network errors | Log and exit with code 1 |
217+
| Dropped live stream | Reconnect with bounded backoff; exit 1 once the hourly budget is spent |
183218
| Auth errors | Clear message: "Invalid token" |
184219
| No Pulse | Exit 1 with "Pulse not enabled" |
185220
| Parse errors | Log raw response, exit 1 |
@@ -210,7 +245,9 @@ windows/amd64. Release builds do not use this — see below.
210245
internal/
211246
├── api/
212247
│ ├── client_test.go # Mock HTTP responses
213-
│ └── websocket_test.go # Live payload parsing and error mapping
248+
│ ├── websocket_test.go # Live payload parsing and error mapping
249+
│ ├── reconnect_test.go # Backoff and budget, on a faked clock
250+
│ └── livestream_test.go # Reconnect over a real socket, via httptest
214251
├── config/
215252
│ └── config_test.go # Config resolution order
216253
└── output/

‎README.md‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,19 @@ powerctl live --home-id 96a14971-525a-4420-aae9-e5aefaf46a81
173173

174174
`powerctl consumption` takes the same flag.
175175

176+
A dropped connection is retried automatically, so the stream can be left
177+
running. The wait starts at 5 seconds and doubles to a 5-minute ceiling, and no
178+
more than 10 connections are opened in any rolling hour — Tibber allows 20, and
179+
a manual restart needs some left. Progress notes go to stderr, so `--format
180+
json` output stays pipeable.
181+
182+
Failures a reconnect cannot fix — an invalid token, an unknown home ID — still
183+
exit 1 immediately instead of retrying. To get that for every error:
184+
185+
```bash
186+
powerctl live --no-reconnect
187+
```
188+
176189
### Output Formats
177190

178191
Default output is beautiful colored CLI. Change format with `--format`:
@@ -247,9 +260,12 @@ make lint # Run linter (requires golangci-lint)
247260
- The ID passed to `--home-id` is not on your account. List valid IDs with `powerctl home`
248261

249262
**Live stream disconnects**
250-
- Rate limit is 20 connections/hour
251-
- There is no automatic reconnect — the command exits with code 1 and the error.
252-
Re-run it, or wrap it in a supervisor if you need it to stay up
263+
- Dropped connections are retried automatically — watch stderr for
264+
`Reconnecting in ...`
265+
- Rate limit is 20 connections/hour. `powerctl` spends at most 10 of them, then
266+
stops with "reconnect budget exhausted" rather than taking the rest; wait for
267+
the hour to roll over before restarting
268+
- `--no-reconnect` restores the old behaviour of exiting 1 on the first error
253269

254270
## Contributing
255271

‎internal/api/livestream_test.go‎

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"sync"
11+
"testing"
12+
"time"
13+
14+
"github.com/coder/websocket"
15+
16+
"github.com/kristofferrisa/powerctl-cli/internal/models"
17+
)
18+
19+
// liveServer is a minimal graphql-transport-ws server standing in for Tibber, so
20+
// the reconnect path can be exercised over a real socket rather than a stub.
21+
type liveServer struct {
22+
*httptest.Server
23+
24+
mu sync.Mutex
25+
conns int
26+
}
27+
28+
// startLiveServer accepts WebSocket connections and hands each one to session,
29+
// already past connection_init, with the 1-based connection number.
30+
func startLiveServer(t *testing.T, session func(ctx context.Context, conn *websocket.Conn, n int)) *liveServer {
31+
t.Helper()
32+
33+
s := &liveServer{}
34+
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35+
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
36+
Subprotocols: []string{"graphql-transport-ws"},
37+
})
38+
if err != nil {
39+
return
40+
}
41+
defer conn.CloseNow()
42+
43+
s.mu.Lock()
44+
s.conns++
45+
n := s.conns
46+
s.mu.Unlock()
47+
48+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
49+
defer cancel()
50+
51+
if _, _, err := conn.Read(ctx); err != nil { // connection_init
52+
return
53+
}
54+
session(ctx, conn, n)
55+
}))
56+
t.Cleanup(s.Close)
57+
return s
58+
}
59+
60+
func (s *liveServer) connections() int {
61+
s.mu.Lock()
62+
defer s.mu.Unlock()
63+
return s.conns
64+
}
65+
66+
// client returns a LiveClient pointed at this server.
67+
func (s *liveServer) client() *LiveClient {
68+
c := NewLiveClient("test-token", "test-home")
69+
c.endpoint = "ws" + strings.TrimPrefix(s.URL, "http")
70+
return c
71+
}
72+
73+
func send(ctx context.Context, conn *websocket.Conn, msgType string, payload string) error {
74+
data, err := json.Marshal(wsMessage{Type: msgType, ID: "1", Payload: json.RawMessage(payload)})
75+
if err != nil {
76+
return err
77+
}
78+
return conn.Write(ctx, websocket.MessageText, data)
79+
}
80+
81+
func ackAndAwaitSubscribe(ctx context.Context, conn *websocket.Conn) error {
82+
if err := send(ctx, conn, "connection_ack", ""); err != nil {
83+
return err
84+
}
85+
_, _, err := conn.Read(ctx) // subscribe
86+
return err
87+
}
88+
89+
const measurementPayload = `{"data":{"liveMeasurement":{"timestamp":"2026-01-01T12:00:00Z","power":1234}}}`
90+
91+
// fastPolicy retries almost immediately so the tests do not wait on real backoff.
92+
func fastPolicy() ReconnectPolicy {
93+
p := DefaultReconnectPolicy()
94+
p.BaseDelay = time.Millisecond
95+
p.MaxDelay = time.Millisecond
96+
return p
97+
}
98+
99+
func TestSubscribeWithReconnect_RecoversFromDroppedConnection(t *testing.T) {
100+
server := startLiveServer(t, func(ctx context.Context, conn *websocket.Conn, n int) {
101+
if err := ackAndAwaitSubscribe(ctx, conn); err != nil {
102+
return
103+
}
104+
if err := send(ctx, conn, "next", measurementPayload); err != nil {
105+
return
106+
}
107+
if n == 1 {
108+
conn.CloseNow() // the blip: no close handshake, just a dead socket
109+
return
110+
}
111+
_ = send(ctx, conn, "complete", "")
112+
})
113+
114+
var measurements []*models.LiveMeasurement
115+
err := server.client().SubscribeWithReconnect(context.Background(), fastPolicy(),
116+
func(m *models.LiveMeasurement) error {
117+
measurements = append(measurements, m)
118+
return nil
119+
})
120+
121+
if err != nil {
122+
t.Fatalf("expected the stream to recover, got %v", err)
123+
}
124+
if server.connections() != 2 {
125+
t.Errorf("expected 2 connections, got %d", server.connections())
126+
}
127+
if len(measurements) != 2 {
128+
t.Fatalf("expected 2 measurements across the reconnect, got %d", len(measurements))
129+
}
130+
for i, m := range measurements {
131+
if m == nil || m.Power != 1234 {
132+
t.Errorf("measurement %d: expected 1234 W, got %v", i, m)
133+
}
134+
}
135+
}
136+
137+
func TestSubscribeWithReconnect_DoesNotRetryFatalErrors(t *testing.T) {
138+
tests := []struct {
139+
name string
140+
session func(ctx context.Context, conn *websocket.Conn, n int)
141+
wantErr string
142+
}{
143+
{
144+
name: "rejected token",
145+
session: func(ctx context.Context, conn *websocket.Conn, _ int) {
146+
_ = send(ctx, conn, "connection_error", `{"message":"Invalid token"}`)
147+
},
148+
wantErr: "connection rejected",
149+
},
150+
{
151+
name: "token refused with close code 4403",
152+
session: func(_ context.Context, conn *websocket.Conn, _ int) {
153+
_ = conn.Close(statusInvalidToken, "Invalid token")
154+
},
155+
wantErr: "4403",
156+
},
157+
{
158+
name: "unknown home ID",
159+
session: func(ctx context.Context, conn *websocket.Conn, _ int) {
160+
if err := ackAndAwaitSubscribe(ctx, conn); err != nil {
161+
return
162+
}
163+
_ = send(ctx, conn, "next", `{"errors":[{"message":"home not found"}],"data":{"liveMeasurement":null}}`)
164+
},
165+
wantErr: "invalid or non-existing home ID",
166+
},
167+
{
168+
name: "subscription error",
169+
session: func(ctx context.Context, conn *websocket.Conn, _ int) {
170+
if err := ackAndAwaitSubscribe(ctx, conn); err != nil {
171+
return
172+
}
173+
_ = send(ctx, conn, "error", `[{"message":"query is not valid"}]`)
174+
},
175+
wantErr: "subscription error",
176+
},
177+
}
178+
179+
for _, tt := range tests {
180+
t.Run(tt.name, func(t *testing.T) {
181+
server := startLiveServer(t, tt.session)
182+
183+
err := server.client().SubscribeWithReconnect(context.Background(), fastPolicy(),
184+
func(*models.LiveMeasurement) error { return nil })
185+
186+
if err == nil {
187+
t.Fatal("expected an error, got nil")
188+
}
189+
if !strings.Contains(err.Error(), tt.wantErr) {
190+
t.Errorf("expected an error containing %q, got %q", tt.wantErr, err)
191+
}
192+
if errors.Is(err, ErrReconnectBudget) {
193+
t.Error("retried a fatal error until the connection budget ran out")
194+
}
195+
if server.connections() != 1 {
196+
t.Errorf("expected 1 connection attempt, got %d", server.connections())
197+
}
198+
})
199+
}
200+
}
201+
202+
func TestSubscribeWithReconnect_StopsWhenCancelled(t *testing.T) {
203+
ctx, cancel := context.WithCancel(context.Background())
204+
defer cancel()
205+
206+
server := startLiveServer(t, func(sctx context.Context, conn *websocket.Conn, _ int) {
207+
if err := ackAndAwaitSubscribe(sctx, conn); err != nil {
208+
return
209+
}
210+
for {
211+
if err := send(sctx, conn, "next", measurementPayload); err != nil {
212+
return
213+
}
214+
time.Sleep(time.Millisecond)
215+
}
216+
})
217+
218+
received := 0
219+
start := time.Now()
220+
err := server.client().SubscribeWithReconnect(ctx, fastPolicy(),
221+
func(*models.LiveMeasurement) error {
222+
received++
223+
if received == 3 {
224+
cancel() // stand-in for Ctrl+C
225+
}
226+
return nil
227+
})
228+
229+
// Ctrl+C must not wait on a close handshake the server may never answer.
230+
if elapsed := time.Since(start); elapsed > 2*time.Second {
231+
t.Errorf("expected a prompt exit on cancellation, took %v", elapsed)
232+
}
233+
if !errors.Is(err, context.Canceled) {
234+
t.Fatalf("expected context.Canceled, got %v", err)
235+
}
236+
if server.connections() != 1 {
237+
t.Errorf("expected no reconnect after cancellation, got %d connections", server.connections())
238+
}
239+
}
240+
241+
func TestSubscribe_NoReconnectStopsOnFirstError(t *testing.T) {
242+
server := startLiveServer(t, func(ctx context.Context, conn *websocket.Conn, _ int) {
243+
if err := ackAndAwaitSubscribe(ctx, conn); err != nil {
244+
return
245+
}
246+
if err := send(ctx, conn, "next", measurementPayload); err != nil {
247+
return
248+
}
249+
conn.CloseNow()
250+
})
251+
252+
// What `powerctl live --no-reconnect` does: one connection, then out.
253+
err := server.client().Subscribe(context.Background(), func(*models.LiveMeasurement) error { return nil })
254+
255+
if err == nil {
256+
t.Fatal("expected a read error, got nil")
257+
}
258+
if server.connections() != 1 {
259+
t.Errorf("expected 1 connection, got %d", server.connections())
260+
}
261+
}

0 commit comments

Comments
 (0)