diff --git a/pkg/client/client.go b/pkg/client/client.go index 1c09f47..fe298a5 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -25,8 +25,18 @@ import ( "time" ) -// TODO: Implement client retry configuration -// Also support exponential backoff +// 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 ( @@ -92,6 +102,7 @@ type Client struct { tar1090 *Endpoint http *http.Client userAgent string + retry RetryConfig } // Option customizes a Client. @@ -125,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) { diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 453f92a..d9fa433 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -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() diff --git a/pkg/client/query.go b/pkg/client/query.go index 65fed8e..e580053 100644 --- a/pkg/client/query.go +++ b/pkg/client/query.go @@ -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 +} diff --git a/pkg/types/readsb/aircraft.go b/pkg/types/readsb/aircraft.go index 96ca30e..5de3b14 100644 --- a/pkg/types/readsb/aircraft.go +++ b/pkg/types/readsb/aircraft.go @@ -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"` @@ -89,14 +89,14 @@ type Aircraft struct { NavModes []string `json:"nav_modes,omitempty"` // ADS-B version and quality/accuracy indicators. - Version int `json:"version,omitempty"` - NICBaro int `json:"nic_baro,omitempty"` - NACP int `json:"nac_p,omitempty"` - NACV int `json:"nac_v,omitempty"` - SIL int `json:"sil,omitempty"` - SILType string `json:"sil_type,omitempty"` - GVA int `json:"gva,omitempty"` - SDA int `json:"sda,omitempty"` + Version int `json:"version,omitempty"` + NICBaro int `json:"nic_baro,omitempty"` + NACP int `json:"nac_p,omitempty"` + NACV int `json:"nac_v,omitempty"` + SIL int `json:"sil,omitempty"` + SILType SILType `json:"sil_type,omitempty"` + GVA int `json:"gva,omitempty"` + SDA int `json:"sda,omitempty"` // Alert and special position identification flags. Alert int `json:"alert,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. diff --git a/pkg/types/readsb/enums.go b/pkg/types/readsb/enums.go new file mode 100644 index 0000000..ae324b4 --- /dev/null +++ b/pkg/types/readsb/enums.go @@ -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" +) diff --git a/pkg/types/readsb/filter.go b/pkg/types/readsb/filter.go index bc55911..e11e351 100644 --- a/pkg/types/readsb/filter.go +++ b/pkg/types/readsb/filter.go @@ -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 {