Reuse weather API readiness response

This commit is contained in:
2026-08-13 03:38:49 +00:00
parent 57aa27c9de
commit e6450138c2
5 changed files with 95 additions and 59 deletions

View File

@@ -24,9 +24,10 @@ import (
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
currentConditionsEndpoint = "/conditions/current"
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
defaultWarmupEndpoint = "/conditions/current"
defaultWarmupEndpoint = currentConditionsEndpoint
defaultWarmupAttempts = 3
defaultWarmupDelay = time.Second
defaultFetchAttempts = 2
@@ -113,7 +114,8 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
}
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := c.warmup(ctx); err != nil {
warmup, err := c.warmup(ctx)
if err != nil {
return nil, err
}
@@ -127,7 +129,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchObservation(ctx); err != nil {
return nil, err
}
if err := builder.fetchCurrent(ctx); err != nil {
if err := builder.fetchCurrent(ctx, warmup); err != nil {
return nil, err
}
if err := builder.fetchHourly(ctx); err != nil {
@@ -172,6 +174,13 @@ type fetchedSource struct {
source weatherdata.Source
}
type warmupResponse struct {
endpoint string
requestURL *url.URL
body []byte
fetchedAt time.Time
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
var observation weatherdata.Observation
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
@@ -190,14 +199,28 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
func currentConditionsRequest() sourceRequest {
return sourceRequest{
name: config.MissingSourceCurrent,
endpoint: "/conditions/current",
endpoint: currentConditionsEndpoint,
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}, &current)
}
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context, warmup warmupResponse) error {
var current weatherdata.Current
request := currentConditionsRequest()
var (
fetched fetchedSource
ok bool
err error
)
if warmup.endpoint == request.endpoint {
fetched, ok, err = b.fetchWarmupSource(warmup, request, &current)
} else {
fetched, ok, err = b.fetchDecodedSource(ctx, request, &current)
}
if err != nil || !ok {
return err
}
@@ -356,6 +379,21 @@ func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRe
if err != nil || !ok {
return fetchedSource{}, false, err
}
return b.decodeFetchedSource(fetched, request, target)
}
func (b *bundleBuilder) fetchWarmupSource(warmup warmupResponse, request sourceRequest, target any) (fetchedSource, bool, error) {
raw, source, err := b.client.decodeSourceResponse(request.name, request.endpoint, request.query, warmup.requestURL, warmup.body, warmup.fetchedAt)
if err != nil {
return fetchedSource{}, false, err
}
if raw == nil {
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
}
return b.decodeFetchedSource(fetchedSource{raw: raw, source: source}, request, target)
}
func (b *bundleBuilder) decodeFetchedSource(fetched fetchedSource, request sourceRequest, target any) (fetchedSource, bool, error) {
if err := decodeSource(fetched.raw, target); err != nil {
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
}
@@ -441,7 +479,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
if err != nil {
return nil, weatherdata.Source{}, err
}
return c.decodeSourceResponse(sourceName, endpoint, opts, reqURL, body, c.now())
}
func (c *Client) decodeSourceResponse(sourceName string, endpoint string, opts queryOptions, reqURL *url.URL, body []byte, fetchedAt time.Time) (json.RawMessage, weatherdata.Source, error) {
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
@@ -451,7 +492,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
Name: sourceName,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
FetchedAt: c.now(),
FetchedAt: fetchedAt,
}
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
source.Missing = true
@@ -465,7 +506,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func (c *Client) warmup(ctx context.Context) error {
func (c *Client) warmup(ctx context.Context) (warmupResponse, error) {
endpoint := c.warmupEndpoint
if strings.TrimSpace(endpoint) == "" {
endpoint = defaultWarmupEndpoint
@@ -475,56 +516,30 @@ func (c *Client) warmup(ctx context.Context) 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)
return warmupResponse{}, fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
if err := c.warmupOnce(ctx, endpoint); err != nil {
reqURL, body, err := c.warmupOnce(ctx, endpoint)
if err != nil {
lastErr = err
lastRetryable = isRetryableRequestError(err)
} else {
return nil
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
}
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)
return warmupResponse{}, fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
if !lastRetryable {
return lastErr
return warmupResponse{}, lastErr
}
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
return warmupResponse{}, fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
err = fmt.Errorf("fetch %s: %w", endpoint, err)
if ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
}
defer resp.Body.Close()
_, err = readResponseBody(resp.Body)
if err != nil {
return responseReadError(ctx, endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("fetch %s: unexpected HTTP status %d", endpoint, resp.StatusCode)
if isRetryableHTTPStatus(resp.StatusCode) {
return retryableRequestError{err: err}
}
return err
}
return nil
func (c *Client) warmupOnce(ctx context.Context, endpoint string) (*url.URL, []byte, error) {
return c.fetchHTTPOnce(ctx, endpoint, queryOptions{precision: true})
}
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {