Bound Weather API response diagnostics

This commit is contained in:
2026-08-13 00:35:38 +00:00
parent 3c1ebab289
commit c515529b3a
4 changed files with 136 additions and 18 deletions

View File

@@ -138,9 +138,10 @@ It does not retry other HTTP statuses, malformed envelopes, missing data, or
payload decoding failures. A canceled context also stops an in-progress retry payload decoding failures. A canceled context also stops an in-progress retry
delay. delay.
The adapter reads at most 10 MiB from one response body. A non-2xx response, The adapter accepts response bodies up to 10 MiB and rejects larger bodies
request construction failure, read failure, or decode failure includes endpoint before decoding. A non-2xx response reports its relative endpoint and status,
context in its error. without including upstream response text. Request construction, response-limit,
read, and decode failures include endpoint context in their errors.
Retry counts and delays are adapter behavior rather than Weather API request Retry counts and delays are adapter behavior rather than Weather API request
parameters. Do not depend on a particular attempt count when implementing the parameters. Do not depend on a particular attempt count when implementing the

View File

@@ -16,6 +16,10 @@ API base URL, as weather-collection setup errors and fetch failures as
bundle-collection errors. It does not retry, persist, select reports, derive bundle-collection errors. It does not retry, persist, select reports, derive
facts, build modules, invoke Promptkit, or notify Distributor. facts, build modules, invoke Promptkit, or notify Distributor.
Fetch failures retain Weather API endpoint context but do not project upstream
response bodies into application-facing errors. Oversized response bodies fail
collection before source decoding.
## Application Composition ## Application Composition
`internal/app` owns the narrow `Collector` interface used by workflow tests; `internal/app` owns the narrow `Collector` interface used by workflow tests;

View File

@@ -7,6 +7,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -30,8 +31,11 @@ const (
defaultWarmupDelay = time.Second defaultWarmupDelay = time.Second
defaultFetchAttempts = 2 defaultFetchAttempts = 2
defaultFetchRetryDelay = time.Second defaultFetchRetryDelay = time.Second
maxResponseBodyBytes = 10 << 20
) )
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
type Client struct { type Client struct {
baseURL *url.URL baseURL *url.URL
httpClient *http.Client httpClient *http.Client
@@ -497,16 +501,12 @@ func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) _, err = readResponseBody(resp.Body)
if err != nil { if err != nil {
err = fmt.Errorf("read %s response: %w", endpoint, err) return responseReadError(ctx, endpoint, err)
if ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := 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", endpoint, resp.StatusCode)
if isRetryableHTTPStatus(resp.StatusCode) { if isRetryableHTTPStatus(resp.StatusCode) {
return retryableRequestError{err: err} return retryableRequestError{err: err}
} }
@@ -559,16 +559,12 @@ func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryO
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) body, err := readResponseBody(resp.Body)
if err != nil { if err != nil {
err = fmt.Errorf("read %s response: %w", endpoint, err) return reqURL, nil, responseReadError(ctx, endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := 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", endpoint, resp.StatusCode)
if isRetryableHTTPStatus(resp.StatusCode) { if isRetryableHTTPStatus(resp.StatusCode) {
return reqURL, nil, retryableRequestError{err: err} return reqURL, nil, retryableRequestError{err: err}
} }
@@ -577,6 +573,25 @@ func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryO
return reqURL, body, nil return reqURL, body, nil
} }
func readResponseBody(body io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, maxResponseBodyBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxResponseBodyBytes {
return nil, errResponseBodyTooLarge
}
return data, nil
}
func responseReadError(ctx context.Context, endpoint string, err error) error {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if errors.Is(err, errResponseBodyTooLarge) || ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
}
type retryableRequestError struct { type retryableRequestError struct {
err error err error
} }

View File

@@ -201,8 +201,9 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
} }
func TestHTTPErrorIsActionable(t *testing.T) { func TestHTTPErrorIsActionable(t *testing.T) {
const marker = "upstream-secret-marker"
server := fixtureServer(t, map[string]handlerOverride{ server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`}, "/forecast/hourly": {status: http.StatusBadGateway, body: marker + strings.Repeat("x", 4096)},
}, nil) }, nil)
client := newTestClient(t, server.URL+"/", nil) client := newTestClient(t, server.URL+"/", nil)
@@ -213,6 +214,9 @@ func TestHTTPErrorIsActionable(t *testing.T) {
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") { if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error()) t.Fatalf("error = %q, want endpoint and status", err.Error())
} }
if strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
}
} }
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) { func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
@@ -291,6 +295,92 @@ func TestWarmupDoesNotRetryPermanentStatus(t *testing.T) {
} }
} }
func TestWarmupErrorDiagnosticsRedactResponseBody(t *testing.T) {
const marker = "upstream-secret-marker"
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusNotFound, body: marker + strings.Repeat("x", 4096)},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want warmup error")
}
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "404") {
t.Fatalf("error = %q, want warmup endpoint and status", err.Error())
}
if strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
}
}
func TestFetchAcceptsResponseAtBodyLimit(t *testing.T) {
body := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes))
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
if _, err := client.FetchBundle(context.Background()); err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
}
func TestFetchRejectsOversizedResponseWithoutRetry(t *testing.T) {
var requested []string
var narrativeCalls int
oversizedBody := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) + "x"
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
narrativeCalls++
_, _ = w.Write([]byte(oversizedBody))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want oversized response error")
}
if !strings.Contains(err.Error(), "/forecast/narrative") || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
t.Fatalf("error = %q, want endpoint and response limit", err.Error())
}
if narrativeCalls != 1 {
t.Fatalf("narrative calls = %d, want no retry", narrativeCalls)
}
if containsPath(requested, "/alerts/active") {
t.Fatalf("requested paths = %v, want source failure before later fetches", requested)
}
}
func TestWarmupRejectsOversizedResponseWithoutRetry(t *testing.T) {
var requested []string
oversizedBody := paddedJSON(t, `{"data":{}}`, int(maxResponseBodyBytes)) + "x"
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(oversizedBody))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
client.warmupAttempts = 2
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want oversized warmup response error")
}
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
t.Fatalf("error = %q, want warmup endpoint and response limit", err.Error())
}
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
t.Fatalf("warmup requests = %d, want no retry; 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) { func TestFetchRetriesRetryableStatus(t *testing.T) {
var hourlyCalls int var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{ server := fixtureServer(t, map[string]handlerOverride{
@@ -766,6 +856,14 @@ func fixedNow() time.Time {
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
} }
func paddedJSON(t *testing.T, value string, size int) string {
t.Helper()
if len(value) > size {
t.Fatalf("JSON value length = %d, exceeds requested size %d", len(value), size)
}
return value + strings.Repeat(" ", size-len(value))
}
func containsPath(requested []string, path string) bool { func containsPath(requested []string, path string) bool {
for _, rawURL := range requested { for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path { if strings.HasPrefix(rawURL, path+"?") || rawURL == path {