From c0fcbd7c990ee633efcd92122814c0ff142bef1a Mon Sep 17 00:00:00 2001 From: "Rasmus \"Pez\" Wejlgaard" Date: Sun, 26 Apr 2026 20:50:28 +0100 Subject: [PATCH] fix: reliability and 429 backoff (#9) --- cmd/octopus_exporter/client.go | 79 ++++++++++++++++++++++++------- cmd/octopus_exporter/main.go | 34 ++++++++++--- cmd/octopus_exporter/meters.go | 13 ++--- cmd/octopus_exporter/rates.go | 6 ++- cmd/octopus_exporter/rest.go | 20 ++++---- cmd/octopus_exporter/telemetry.go | 3 -- 6 files changed, 108 insertions(+), 47 deletions(-) diff --git a/cmd/octopus_exporter/client.go b/cmd/octopus_exporter/client.go index 9226325..37187b3 100644 --- a/cmd/octopus_exporter/client.go +++ b/cmd/octopus_exporter/client.go @@ -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") } diff --git a/cmd/octopus_exporter/main.go b/cmd/octopus_exporter/main.go index 6847687..021becd 100644 --- a/cmd/octopus_exporter/main.go +++ b/cmd/octopus_exporter/main.go @@ -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) } diff --git a/cmd/octopus_exporter/meters.go b/cmd/octopus_exporter/meters.go index 3345058..4d2d9d8 100644 --- a/cmd/octopus_exporter/meters.go +++ b/cmd/octopus_exporter/meters.go @@ -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 diff --git a/cmd/octopus_exporter/rates.go b/cmd/octopus_exporter/rates.go index 6599d33..6d925a9 100644 --- a/cmd/octopus_exporter/rates.go +++ b/cmd/octopus_exporter/rates.go @@ -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) diff --git a/cmd/octopus_exporter/rest.go b/cmd/octopus_exporter/rest.go index 777b1b2..ed10bad 100644 --- a/cmd/octopus_exporter/rest.go +++ b/cmd/octopus_exporter/rest.go @@ -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 } diff --git a/cmd/octopus_exporter/telemetry.go b/cmd/octopus_exporter/telemetry.go index 0e42856..16a9560 100644 --- a/cmd/octopus_exporter/telemetry.go +++ b/cmd/octopus_exporter/telemetry.go @@ -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 }