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 }