add string type enums and retry config
Publish / release (push) Successful in 1m23s

This commit is contained in:
2026-06-23 22:35:26 -04:00
parent 9b0e05d477
commit 9e94696363
6 changed files with 266 additions and 23 deletions
+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
}