fix: reliability and 429 backoff

This commit is contained in:
Rasmus Wejlgaard 2026-04-26 20:36:35 +01:00
parent ef72127d3c
commit 2fc0500350
6 changed files with 108 additions and 47 deletions

View file

@ -6,11 +6,17 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
) )
var octopusGraphQL = "https://api.octopus.energy/v1/graphql/" var (
octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
httpClient = &http.Client{Timeout: 15 * time.Second}
)
type gqlRequest struct { type gqlRequest struct {
OperationName string `json:"operationName,omitempty"` OperationName string `json:"operationName,omitempty"`
@ -39,11 +45,52 @@ func (f *jsonFloat) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// executeWithRetry executes an HTTP request, retrying on 429 with exponential
// backoff (honouring Retry-After when present). Returns the raw response body.
func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
const maxRetries = 5
backoff := 30 * time.Second
for attempt := 0; attempt <= maxRetries; attempt++ {
req, err := makeReq()
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
if attempt == maxRetries {
return nil, errors.New("rate limited: max retries exceeded")
}
wait := backoff
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil {
wait = time.Duration(secs) * time.Second
}
}
log.Printf("rate limited; retrying in %v (attempt %d/%d)", wait, attempt+1, maxRetries)
time.Sleep(wait)
backoff *= 2
continue
}
raw, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
return raw, nil
}
return nil, errors.New("rate limited: max retries exceeded")
}
func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) { func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
body, err := json.Marshal(req) body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
raw, err := executeWithRetry(func() (*http.Request, error) {
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body)) httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
@ -52,12 +99,8 @@ func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
if authToken != "" { if authToken != "" {
httpReq.Header.Set("Authorization", "JWT "+authToken) httpReq.Header.Set("Authorization", "JWT "+authToken)
} }
resp, err := http.DefaultClient.Do(httpReq) return httpReq, nil
if err != nil { })
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -67,7 +110,11 @@ func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
} }
if errs, ok := result["errors"].([]any); ok && len(errs) > 0 { if errs, ok := result["errors"].([]any); ok && len(errs) > 0 {
if e, ok := errs[0].(map[string]any); ok { if e, ok := errs[0].(map[string]any); ok {
return nil, fmt.Errorf("GraphQL error: %s", e["message"]) msg, _ := e["message"].(string)
if strings.Contains(msg, "JWT") && strings.Contains(msg, "expired") {
return nil, errTokenExpired
}
return nil, fmt.Errorf("GraphQL error: %s", msg)
} }
return nil, errors.New("GraphQL error") return nil, errors.New("GraphQL error")
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -43,7 +44,13 @@ func main() {
log.Fatalf("failed to get initial token: %v", err) log.Fatalf("failed to get initial token: %v", err)
} }
elecMeter, err := resolveMeter(token, electricity) log.Println("discovering meters from account...")
candidates, err := getMeters(token)
if err != nil {
log.Fatalf("failed to discover meters: %v", err)
}
elecMeter, err := resolveMeter(candidates, electricity)
if err != nil { if err != nil {
log.Fatalf("failed to resolve electricity meter: %v", err) log.Fatalf("failed to resolve electricity meter: %v", err)
} }
@ -51,7 +58,7 @@ func main() {
log.Fatal("no electricity smart meter found on account") log.Fatal("no electricity smart meter found on account")
} }
gasMeter, err := resolveMeter(token, gas) gasMeter, err := resolveMeter(candidates, gas)
if err != nil { if err != nil {
log.Fatalf("failed to resolve gas meter: %v", err) log.Fatalf("failed to resolve gas meter: %v", err)
} }
@ -111,18 +118,28 @@ func main() {
} }
}() }()
tryRefresh := func(err error) {
if !errors.Is(err, errTokenExpired) {
return
}
t, e := getKrakenToken(apiKey)
if e != nil {
log.Printf("token refresh failed: %v", e)
return
}
token = t
}
for { for {
// Electricity telemetry (live demand) // Electricity telemetry (live demand)
if elecMeter.deviceID != "" { if elecMeter.deviceID != "" {
reading, err := getLiveConsumption(token, elecMeter.deviceID) reading, err := getLiveConsumption(token, elecMeter.deviceID)
if err != nil { if err != nil {
log.Printf("electricity telemetry error: %v", err) log.Printf("electricity telemetry error: %v", err)
if token, err = getKrakenToken(apiKey); err != nil { tryRefresh(err)
log.Printf("token refresh failed: %v", err)
}
} else { } else {
elecDemand.Set(float64(reading.Demand)) elecDemand.Set(float64(reading.Demand))
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil { if t, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
elecLastRead.Set(float64(t.Unix())) elecLastRead.Set(float64(t.Unix()))
} }
} }
@ -133,9 +150,10 @@ func main() {
reading, err := getLiveConsumption(token, gasMeter.deviceID) reading, err := getLiveConsumption(token, gasMeter.deviceID)
if err != nil { if err != nil {
log.Printf("gas telemetry error: %v", err) log.Printf("gas telemetry error: %v", err)
tryRefresh(err)
} else { } else {
gasDemand.Set(float64(reading.Demand)) gasDemand.Set(float64(reading.Demand))
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil { if t, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
gasLastRead.Set(float64(t.Unix())) gasLastRead.Set(float64(t.Unix()))
} }
} }
@ -167,6 +185,7 @@ func main() {
rates, err := getRates(token) rates, err := getRates(token)
if err != nil { if err != nil {
log.Printf("rates error: %v", err) log.Printf("rates error: %v", err)
tryRefresh(err)
} else { } else {
unitRate := rates.ElectricityUnitRate unitRate := rates.ElectricityUnitRate
if rates.ElectricityIsAgile && rates.ElectricityProductCode != "" && rates.ElectricityTariffCode != "" { if rates.ElectricityIsAgile && rates.ElectricityProductCode != "" && rates.ElectricityTariffCode != "" {
@ -190,6 +209,7 @@ func main() {
balance, err := getAccountBalance(token) balance, err := getAccountBalance(token)
if err != nil { if err != nil {
log.Printf("account balance error: %v", err) log.Printf("account balance error: %v", err)
tryRefresh(err)
} else { } else {
accountBalance.Set(balance) accountBalance.Set(balance)
} }

View file

@ -57,7 +57,10 @@ func getMeters(token string) ([]meterCandidate, error) {
for _, a := range accounts { for _, a := range accounts {
props, _ := a.(map[string]any)["properties"].([]any) props, _ := a.(map[string]any)["properties"].([]any)
for _, p := range props { for _, p := range props {
pm := p.(map[string]any) pm, ok := p.(map[string]any)
if !ok {
continue
}
for _, mp := range toSlice(pm["electricityMeterPoints"]) { for _, mp := range toSlice(pm["electricityMeterPoints"]) {
mpan, _ := mp.(map[string]any)["mpan"].(string) mpan, _ := mp.(map[string]any)["mpan"].(string)
@ -99,7 +102,7 @@ func getMeters(token string) ([]meterCandidate, error) {
// resolveMeter finds the meter matching the env var filters for the given kind. // resolveMeter finds the meter matching the env var filters for the given kind.
// Returns (nil, nil) if no meter of that kind exists on the account. // Returns (nil, nil) if no meter of that kind exists on the account.
func resolveMeter(token string, kind meterKind) (*resolvedMeter, error) { func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter, error) {
var wantDeviceID, wantID, wantSerial string var wantDeviceID, wantID, wantSerial string
switch kind { switch kind {
case electricity: case electricity:
@ -112,12 +115,6 @@ func resolveMeter(token string, kind meterKind) (*resolvedMeter, error) {
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL") wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
} }
log.Printf("discovering %s meters from account...", kind)
candidates, err := getMeters(token)
if err != nil {
return nil, err
}
for _, c := range candidates { for _, c := range candidates {
if c.kind != kind { if c.kind != kind {
continue continue

View file

@ -65,7 +65,11 @@ func getRates(token string) (*tariffRates, error) {
// activeAgreementTariff returns the tariff map for the agreement with validTo == null. // activeAgreementTariff returns the tariff map for the agreement with validTo == null.
func activeAgreementTariff(meterPoint any) map[string]any { func activeAgreementTariff(meterPoint any) map[string]any {
for _, ag := range toSlice(meterPoint.(map[string]any)["agreements"]) { mp, ok := meterPoint.(map[string]any)
if !ok {
return nil
}
for _, ag := range toSlice(mp["agreements"]) {
agm := ag.(map[string]any) agm := ag.(map[string]any)
if agm["validTo"] == nil { if agm["validTo"] == nil {
tariff, _ := agm["tariff"].(map[string]any) tariff, _ := agm["tariff"].(map[string]any)

View file

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/url" "net/url"
) )
@ -14,17 +13,14 @@ func doREST(path string, params url.Values) (map[string]any, error) {
if len(params) > 0 { if len(params) > 0 {
u += "?" + params.Encode() u += "?" + params.Encode()
} }
raw, err := executeWithRetry(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, u, nil) req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.SetBasicAuth(apiKey, "") req.SetBasicAuth(apiKey, "")
resp, err := http.DefaultClient.Do(req) return req, nil
if err != nil { })
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -18,9 +18,6 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n", Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n",
}, token) }, token)
if err != nil { if err != nil {
if err.Error() == "GraphQL error: Signature of the JWT has expired." {
return nil, errTokenExpired
}
return nil, err return nil, err
} }