Validate Weather API endpoints and retries

This commit is contained in:
2026-08-13 00:32:22 +00:00
parent 2d956f7315
commit 3c1ebab289
7 changed files with 125 additions and 12 deletions

View File

@@ -75,6 +75,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
}
if !strings.EqualFold(baseURL.Scheme, "http") && !strings.EqualFold(baseURL.Scheme, "https") {
return nil, fmt.Errorf("weather_api.base_url must use http or https")
}
timeout := cfg.WeatherAPI.Timeout
if timeout <= 0 {
@@ -453,22 +456,27 @@ func (c *Client) warmup(ctx context.Context) error {
}
attempts := positiveAttemptCount(c.warmupAttempts)
var lastErr error
var lastRetryable bool
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
if err := c.warmupOnce(ctx, endpoint); err != nil {
lastErr = err
lastRetryable = isRetryableRequestError(err)
} else {
return nil
}
if attempt == attempts {
if !lastRetryable || attempt == attempts {
break
}
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
if !lastRetryable {
return lastErr
}
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
@@ -481,16 +489,28 @@ func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", endpoint, err)
err = fmt.Errorf("fetch %s: %w", endpoint, err)
if ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("read %s response: %w", endpoint, err)
err = fmt.Errorf("read %s response: %w", endpoint, err)
if ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
if isRetryableHTTPStatus(resp.StatusCode) {
return retryableRequestError{err: err}
}
return err
}
return nil
}

View File

@@ -3,6 +3,7 @@ package weatherapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -15,6 +16,12 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFetchBundleFromFixtures(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
@@ -265,6 +272,25 @@ func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
}
}
func TestWarmupDoesNotRetryPermanentStatus(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusNotFound, body: `not found`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("FetchBundle() error = %v, want non-retryable warmup status", err)
}
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
t.Fatalf("warmup requests = %d, want 1; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestFetchRetriesRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
@@ -312,6 +338,40 @@ func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
}
}
func TestNewValidatesWeatherAPIBaseURLSchemeWithoutRequests(t *testing.T) {
requests := 0
httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
requests++
return nil, errors.New("unexpected request")
})}
tests := []struct {
name string
baseURL string
wantErr string
}{
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testConfig(tt.baseURL)
_, err := New(cfg, WithHTTPClient(httpClient))
if tt.wantErr == "" {
if err != nil {
t.Fatalf("New() error = %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("New() error = %v, want %q", err, tt.wantErr)
}
})
}
if requests != 0 {
t.Fatalf("HTTP requests = %d, want none", requests)
}
}
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{