mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-05-06 04:14:44 +00:00
fix: reliability and 429 backoff
This commit is contained in:
parent
ef72127d3c
commit
2fc0500350
6 changed files with 108 additions and 47 deletions
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue