Files
rmcguire 9e94696363
Publish / release (push) Successful in 1m23s
add string type enums and retry config
2026-06-23 22:35:26 -04:00

166 lines
5.3 KiB
Go

package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/readsb"
"gitea.libretechconsulting.com/rmcguire/wingbits/pkg/types/wingbits"
)
// Aircraft fetches and decodes aircraft.json. When one or more filters are
// supplied, only matching aircraft are retained in the returned report.
func (c *Client) Aircraft(ctx context.Context, filters ...readsb.AircraftFilter) (*readsb.AircraftReport, error) {
ep, port := c.readsbEndpoint()
report, err := getJSON[readsb.AircraftReport](ctx, c, ep.url(port, true, "aircraft.json"))
if err != nil {
return nil, err
}
report.Aircraft = report.Filter(filters...)
return report, nil
}
// Receiver fetches and decodes receiver.json.
func (c *Client) Receiver(ctx context.Context) (*readsb.Receiver, error) {
ep, port := c.readsbEndpoint()
return getJSON[readsb.Receiver](ctx, c, ep.url(port, true, "receiver.json"))
}
// Stats fetches and decodes stats.json.
func (c *Client) Stats(ctx context.Context) (*readsb.Stats, error) {
ep, port := c.readsbEndpoint()
return getJSON[readsb.Stats](ctx, c, ep.url(port, true, "stats.json"))
}
// Outline fetches and decodes outline.json (the receiver range polygon). It is
// only served by the tar1090 root, so a tar1090 endpoint must be configured.
func (c *Client) Outline(ctx context.Context) (*readsb.Outline, error) {
if c.tar1090 == nil {
return nil, fmt.Errorf("wingbits: outline.json requires a tar1090 endpoint")
}
return getJSON[readsb.Outline](ctx, c, c.tar1090.url(DefaultTar1090Port, true, "outline.json"))
}
// Diagnostics fetches and decodes /network/diagnostics from the Wingbits root.
func (c *Client) Diagnostics(ctx context.Context) (*wingbits.Diagnostics, error) {
return getJSON[wingbits.Diagnostics](ctx, c, c.wingbits.url(DefaultWingbitsPort, false, "network", "diagnostics"))
}
// Status fetches and decodes the Tailscale status from the Wingbits root.
func (c *Client) Status(ctx context.Context) (*wingbits.TailscaleStatus, error) {
return getJSON[wingbits.TailscaleStatus](ctx, c, c.wingbits.url(DefaultWingbitsPort, false, "tailscale", "status"))
}
// Metrics fetches /metrics from the Wingbits root and parses the Prometheus
// text exposition format into structured metric families.
func (c *Client) Metrics(ctx context.Context) (*wingbits.Metrics, error) {
body, err := c.get(ctx, c.wingbits.url(DefaultWingbitsPort, false, "metrics"))
if err != nil {
return nil, err
}
defer body.Close()
return wingbits.ParseMetrics(body)
}
// getJSON fetches a URL and decodes its JSON body into a freshly allocated T.
func getJSON[T any](ctx context.Context, c *Client, url string) (*T, error) {
body, err := c.get(ctx, url)
if err != nil {
return nil, err
}
defer body.Close()
out := new(T)
if err := json.NewDecoder(body).Decode(out); err != nil {
return nil, fmt.Errorf("wingbits: decoding %s: %w", url, err)
}
return out, nil
}
// statusError reports a non-200 HTTP status returned by a station.
type statusError struct {
url string
status string
code int
}
func (e *statusError) Error() string {
return fmt.Sprintf("wingbits: GET %s: unexpected status %s", e.url, e.status)
}
// get performs a GET and returns the response body, which the caller must close.
// Transient failures are retried per the client's RetryConfig.
func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) {
for attempt := 0; ; attempt++ {
body, err := c.doGet(ctx, url)
if err == nil || attempt >= c.retry.MaxRetries || !retryable(err) {
return body, err
}
if werr := c.waitBackoff(ctx, attempt); werr != nil {
return nil, werr
}
}
}
// doGet performs a single GET attempt.
func (c *Client) doGet(ctx context.Context, url string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("wingbits: building request: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("wingbits: GET %s: %w", url, err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, &statusError{url: url, status: resp.Status, code: resp.StatusCode}
}
return resp.Body, nil
}
// retryable reports whether err is a transient failure worth retrying: any
// transport error, or a 5xx response. Context cancellation is never retried.
func retryable(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if se, ok := errors.AsType[*statusError](err); ok {
return se.code >= 500
}
return true
}
// waitBackoff sleeps for the attempt's backoff delay or until ctx is done.
func (c *Client) waitBackoff(ctx context.Context, attempt int) error {
t := time.NewTimer(c.backoff(attempt))
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// backoff returns the delay before the given retry attempt (0-indexed),
// doubling BaseDelay each attempt and capping at MaxDelay.
func (c *Client) backoff(attempt int) time.Duration {
d := c.retry.BaseDelay
for range attempt {
d *= 2
if c.retry.MaxDelay > 0 && d >= c.retry.MaxDelay {
return c.retry.MaxDelay
}
}
if c.retry.MaxDelay > 0 && d > c.retry.MaxDelay {
return c.retry.MaxDelay
}
return d
}