This commit is contained in:
+68
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user