166 lines
5.3 KiB
Go
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
|
|
}
|