3 Commits

Author SHA1 Message Date
rmcguire 9e94696363 add string type enums and retry config
Publish / release (push) Successful in 1m23s
2026-06-23 22:35:26 -04:00
rmcguire 9b0e05d477 Merge pull request 'Add TODO items to client' (#1) from feat-client-retries into main
Reviewed-on: #1
2026-06-24 01:01:55 +00:00
rmcguire 8ebccfa558 Add TODO items to client
Prepare for implementation of client retry support
2026-06-23 20:57:28 -04:00
6 changed files with 267 additions and 21 deletions
+19
View File
@@ -25,6 +25,19 @@ import (
"time"
)
// RetryConfig controls how transient request failures (transport errors and 5xx
// responses) are retried. The zero value disables retries.
type RetryConfig struct {
// MaxRetries is the number of additional attempts made after the initial
// request. Zero means no retries.
MaxRetries int
// BaseDelay is the wait before the first retry. It doubles on each
// subsequent retry (exponential backoff). Zero means retry immediately.
BaseDelay time.Duration
// MaxDelay caps the per-retry backoff delay. Zero means no cap.
MaxDelay time.Duration
}
// Default ports exposed by an MGW310 station.
const (
DefaultWingbitsPort = 8088
@@ -89,6 +102,7 @@ type Client struct {
tar1090 *Endpoint
http *http.Client
userAgent string
retry RetryConfig
}
// Option customizes a Client.
@@ -122,6 +136,11 @@ func WithUserAgent(ua string) Option {
return func(c *Client) { c.userAgent = ua }
}
// WithRetry enables retries with exponential backoff for transient failures.
func WithRetry(cfg RetryConfig) Option {
return func(c *Client) { c.retry = cfg }
}
// New constructs a Client from options. A Wingbits endpoint with a non-empty
// Host must be supplied via WithWingbitsEndpoint.
func New(opts ...Option) (*Client, error) {
+55
View File
@@ -144,6 +144,61 @@ func TestClientWingbitsEndpoints(t *testing.T) {
}
}
func TestClientRetry(t *testing.T) {
// Fail with 503 for the first two attempts, then succeed; the client must
// retry past the failures and return the eventual body.
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if hits++; hits <= 2 {
http.Error(w, "warming up", http.StatusServiceUnavailable)
return
}
w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port}),
WithRetry(RetryConfig{MaxRetries: 3, BaseDelay: time.Millisecond, MaxDelay: 5 * time.Millisecond}),
)
if err != nil {
t.Fatal(err)
}
if _, err := c.Status(context.Background()); err != nil {
t.Fatalf("status after retries: %v", err)
}
if hits != 3 {
t.Fatalf("expected 3 attempts, got %d", hits)
}
}
func TestClientRetryGivesUp(t *testing.T) {
// A 404 is not retryable: the client must fail after a single attempt even
// with retries configured.
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
http.Error(w, "nope", http.StatusNotFound)
}))
t.Cleanup(srv.Close)
host, port := splitHostPort(t, strings.TrimPrefix(srv.URL, "http://"))
c, err := New(
WithWingbitsEndpoint(Endpoint{Host: host, Port: port}),
WithRetry(RetryConfig{MaxRetries: 3, BaseDelay: time.Millisecond}),
)
if err != nil {
t.Fatal(err)
}
if _, err := c.Status(context.Background()); err == nil {
t.Fatal("expected error on 404")
}
if hits != 1 {
t.Fatalf("expected 1 attempt for non-retryable status, got %d", hits)
}
}
func TestPollAircraft(t *testing.T) {
c := newTestClient(t)
ctx := t.Context()
+68 -1
View File
@@ -3,9 +3,11 @@ 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"
@@ -79,8 +81,33 @@ func getJSON[T any](ctx context.Context, c *Client, url string) (*T, error) {
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)
@@ -92,7 +119,47 @@ func (c *Client) get(ctx context.Context, url string) (io.ReadCloser, error) {
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("wingbits: GET %s: unexpected status %s", url, resp.Status)
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
}
+5 -5
View File
@@ -32,7 +32,7 @@ type Aircraft struct {
// indicates a non-ICAO address (TIS-B).
Hex string `json:"hex"`
// Type is the message source/type, e.g. "adsb_icao", "mode_s", "mlat".
Type string `json:"type"`
Type MessageType `json:"type"`
// Flight is the callsign. readsb space-pads it; use Callsign for a trimmed value.
Flight string `json:"flight,omitempty"`
// Registration (r) and aircraft type (t) come from an optional database.
@@ -61,9 +61,9 @@ type Aircraft struct {
// Squawk is the Mode A code as 4 octal digits.
Squawk string `json:"squawk,omitempty"`
// Emergency is the emergency/priority status; "none" when not declared.
Emergency string `json:"emergency,omitempty"`
Emergency EmergencyStatus `json:"emergency,omitempty"`
// Category is the emitter category, e.g. "A0".."A7", "B0".. (see README-json).
Category string `json:"category,omitempty"`
Category EmitterCategory `json:"category,omitempty"`
// Lat/Lon are the last known position in decimal degrees.
Lat float64 `json:"lat,omitempty"`
@@ -94,7 +94,7 @@ type Aircraft struct {
NACP int `json:"nac_p,omitempty"`
NACV int `json:"nac_v,omitempty"`
SIL int `json:"sil,omitempty"`
SILType string `json:"sil_type,omitempty"`
SILType SILType `json:"sil_type,omitempty"`
GVA int `json:"gva,omitempty"`
SDA int `json:"sda,omitempty"`
@@ -133,7 +133,7 @@ func (a Aircraft) HasPosition() bool { return a.Lat != 0 || a.Lon != 0 }
// InEmergency reports whether a non-routine emergency/priority code is set.
func (a Aircraft) InEmergency() bool {
return a.Emergency != "" && a.Emergency != "none"
return a.Emergency != "" && a.Emergency != EmergencyNone
}
// SeenFor returns the time since the last message was received.
+96
View File
@@ -0,0 +1,96 @@
package readsb
// MessageType is the source/type of an aircraft's data (the "type" field). It is
// a named string so callers can compare against typed constants; it decodes from
// and encodes to JSON exactly like a plain string. readsb lists these values in
// descending order of trustworthiness in README-json.md.
type MessageType string
const (
TypeADSBICAO MessageType = "adsb_icao" // Mode S/ADS-B transponder, ICAO address
TypeADSBICAONT MessageType = "adsb_icao_nt" // ADS-B non-transponder emitter, ICAO address
TypeADSRICAO MessageType = "adsr_icao" // ADS-B rebroadcast (e.g. UAT), ICAO address
TypeTISBICAO MessageType = "tisb_icao" // TIS-B about a non-ADS-B target, ICAO address
TypeADSC MessageType = "adsc" // ADS-C via satellite downlink
TypeMLAT MessageType = "mlat" // position from multilateration
TypeOther MessageType = "other" // miscellaneous Basestation/SBS data
TypeModeS MessageType = "mode_s" // Mode S only, no position
TypeADSBOther MessageType = "adsb_other" // ADS-B transponder, non-ICAO address
TypeADSROther MessageType = "adsr_other" // ADS-B rebroadcast, non-ICAO address
TypeTISBOther MessageType = "tisb_other" // TIS-B about a non-ADS-B target, non-ICAO address
TypeTISBTrackfile MessageType = "tisb_trackfile" // TIS-B keyed by track/file id (radar)
)
// EmitterCategory is the ADS-B emitter category (the "category" field): a class
// letter A-D and a digit 0-7. The A0/B0/C0/D0 codes mean "no category info"; the
// remaining D codes and a few others are reserved. Decodes/encodes as a string.
type EmitterCategory string
const (
CatNoInfo EmitterCategory = "A0" // also B0/C0/D0: no category information
CatLight EmitterCategory = "A1" // < 15,500 lb
CatSmall EmitterCategory = "A2" // 15,500-75,000 lb
CatLarge EmitterCategory = "A3" // 75,000-300,000 lb
CatHighVortex EmitterCategory = "A4" // high-vortex large, e.g. B757
CatHeavy EmitterCategory = "A5" // > 300,000 lb
CatHighPerf EmitterCategory = "A6" // > 5g and > 400 kt
CatRotorcraft EmitterCategory = "A7"
CatGlider EmitterCategory = "B1" // glider / sailplane
CatLighterThanAir EmitterCategory = "B2"
CatParachutist EmitterCategory = "B3" // parachutist / skydiver
CatUltralight EmitterCategory = "B4" // ultralight / hang-glider / paraglider
CatUAV EmitterCategory = "B6" // unmanned aerial vehicle
CatSpace EmitterCategory = "B7" // space / transatmospheric vehicle
CatSurfaceEmergency EmitterCategory = "C1" // surface vehicle, emergency
CatSurfaceService EmitterCategory = "C2" // surface vehicle, service
CatPointObstacle EmitterCategory = "C3" // point obstacle (incl. tethered balloons)
CatClusterObstacle EmitterCategory = "C4"
CatLineObstacle EmitterCategory = "C5"
)
// catDescriptions maps every defined emitter code (including reserved ones) to a
// human label. Codes absent from the map are undefined.
var catDescriptions = map[EmitterCategory]string{
"A0": "no information", "A1": "light", "A2": "small", "A3": "large",
"A4": "high-vortex large", "A5": "heavy", "A6": "high performance", "A7": "rotorcraft",
"B0": "no information", "B1": "glider/sailplane", "B2": "lighter-than-air",
"B3": "parachutist", "B4": "ultralight", "B5": "reserved",
"B6": "unmanned aerial vehicle", "B7": "space/transatmospheric",
"C0": "no information", "C1": "surface emergency vehicle", "C2": "surface service vehicle",
"C3": "point obstacle", "C4": "cluster obstacle", "C5": "line obstacle",
"C6": "reserved", "C7": "reserved",
"D0": "no information", "D1": "reserved", "D2": "reserved", "D3": "reserved",
"D4": "reserved", "D5": "reserved", "D6": "reserved", "D7": "reserved",
}
// Description returns the human-readable label for the emitter category, or ""
// if the code is not a defined A0-D7 value.
func (c EmitterCategory) Description() string { return catDescriptions[c] }
// EmergencyStatus is the emergency/priority status (the "emergency" field).
// EmergencyNone means no emergency is declared. Decodes/encodes as a string.
type EmergencyStatus string
const (
EmergencyNone EmergencyStatus = "none"
EmergencyGeneral EmergencyStatus = "general"
EmergencyLifeguard EmergencyStatus = "lifeguard" // medical/lifeguard flight
EmergencyMinFuel EmergencyStatus = "minfuel" // minimum fuel
EmergencyNoRadio EmergencyStatus = "nordo" // no radio communication
EmergencyUnlawful EmergencyStatus = "unlawful" // unlawful interference
EmergencyDowned EmergencyStatus = "downed" // downed aircraft
EmergencyReserved EmergencyStatus = "reserved"
)
// SILType describes how the SIL (source integrity level) probability is scaled
// (the "sil_type" field): per flight hour or per sample. Decodes/encodes as a string.
type SILType string
const (
SILUnknown SILType = "unknown"
SILPerHour SILType = "perhour"
SILPerSample SILType = "persample"
)
+17 -8
View File
@@ -66,16 +66,16 @@ func WithSquawk(codes ...string) AircraftFilter {
return func(a *Aircraft) bool { return set[strings.ToLower(a.Squawk)] }
}
// WithCategory keeps aircraft of any of the given emitter categories (e.g. "A3").
func WithCategory(cats ...string) AircraftFilter {
set := lowerSet(nil, cats, "")
return func(a *Aircraft) bool { return set[strings.ToLower(a.Category)] }
// WithCategory keeps aircraft of any of the given emitter categories (e.g. CatLarge).
func WithCategory(cats ...EmitterCategory) AircraftFilter {
set := lowerSet(nil, strs(cats), "")
return func(a *Aircraft) bool { return set[strings.ToLower(string(a.Category))] }
}
// WithType keeps aircraft of any of the given source types (e.g. "adsb_icao", "mlat").
func WithType(types ...string) AircraftFilter {
set := lowerSet(nil, types, "")
return func(a *Aircraft) bool { return set[strings.ToLower(a.Type)] }
// WithType keeps aircraft of any of the given source types (e.g. TypeADSBICAO, TypeMLAT).
func WithType(types ...MessageType) AircraftFilter {
set := lowerSet(nil, strs(types), "")
return func(a *Aircraft) bool { return set[strings.ToLower(string(a.Type))] }
}
// InEmergency keeps only aircraft squawking a non-routine emergency/priority code.
@@ -138,6 +138,15 @@ func Any(filters ...AircraftFilter) AircraftFilter {
// lowerSet builds a lowercased lookup set, optionally pre-processing each value
// with trim(value, cut) when trim is non-nil.
// strs widens a slice of any string-kind type to []string for the set helpers.
func strs[T ~string](in []T) []string {
out := make([]string, len(in))
for i, v := range in {
out[i] = string(v)
}
return out
}
func lowerSet(trim func(string, string) string, values []string, cut string) map[string]bool {
set := make(map[string]bool, len(values))
for _, v := range values {