mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-05-06 04:14:44 +00:00
fix: reliability and 429 backoff (#9)
Some checks are pending
Release / release (push) Waiting to run
Some checks are pending
Release / release (push) Waiting to run
This commit is contained in:
parent
ef72127d3c
commit
c0fcbd7c99
6 changed files with 108 additions and 47 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue