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"
"fmt"
"io"
"log"
"net/http"
"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 {
OperationName string `json:"operationName,omitempty"`
@ -39,25 +45,62 @@ func (f *jsonFloat) UnmarshalJSON(data []byte) error {
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) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
if authToken != "" {
httpReq.Header.Set("Authorization", "JWT "+authToken)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
raw, err := executeWithRetry(func() (*http.Request, error) {
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
if authToken != "" {
httpReq.Header.Set("Authorization", "JWT "+authToken)
}
return httpReq, nil
})
if err != nil {
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 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")
}

View file

@ -1,6 +1,7 @@
package main
import (
"errors"
"log"
"net/http"
"os"
@ -43,7 +44,13 @@ func main() {
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 {
log.Fatalf("failed to resolve electricity meter: %v", err)
}
@ -51,7 +58,7 @@ func main() {
log.Fatal("no electricity smart meter found on account")
}
gasMeter, err := resolveMeter(token, gas)
gasMeter, err := resolveMeter(candidates, gas)
if err != nil {
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 {
// Electricity telemetry (live demand)
if elecMeter.deviceID != "" {
reading, err := getLiveConsumption(token, elecMeter.deviceID)
if err != nil {
log.Printf("electricity telemetry error: %v", err)
if token, err = getKrakenToken(apiKey); err != nil {
log.Printf("token refresh failed: %v", err)
}
tryRefresh(err)
} else {
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()))
}
}
@ -133,9 +150,10 @@ func main() {
reading, err := getLiveConsumption(token, gasMeter.deviceID)
if err != nil {
log.Printf("gas telemetry error: %v", err)
tryRefresh(err)
} else {
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()))
}
}
@ -167,6 +185,7 @@ func main() {
rates, err := getRates(token)
if err != nil {
log.Printf("rates error: %v", err)
tryRefresh(err)
} else {
unitRate := rates.ElectricityUnitRate
if rates.ElectricityIsAgile && rates.ElectricityProductCode != "" && rates.ElectricityTariffCode != "" {
@ -190,6 +209,7 @@ func main() {
balance, err := getAccountBalance(token)
if err != nil {
log.Printf("account balance error: %v", err)
tryRefresh(err)
} else {
accountBalance.Set(balance)
}

View file

@ -57,7 +57,10 @@ func getMeters(token string) ([]meterCandidate, error) {
for _, a := range accounts {
props, _ := a.(map[string]any)["properties"].([]any)
for _, p := range props {
pm := p.(map[string]any)
pm, ok := p.(map[string]any)
if !ok {
continue
}
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
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.
// 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
switch kind {
case electricity:
@ -112,12 +115,6 @@ func resolveMeter(token string, kind meterKind) (*resolvedMeter, error) {
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 {
if c.kind != kind {
continue

View file

@ -65,7 +65,11 @@ func getRates(token string) (*tariffRates, error) {
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
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)
if agm["validTo"] == nil {
tariff, _ := agm["tariff"].(map[string]any)

View file

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