2026-06-09 20:16:47 +00:00
// Package weatherapi adapts the internal weather API to weather data bundles.
2026-05-29 17:06:39 +00:00
package weatherapi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
2026-08-13 00:35:38 +00:00
"errors"
2026-05-29 17:06:39 +00:00
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
2026-08-13 03:45:55 +00:00
"sync"
2026-05-29 17:06:39 +00:00
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
2026-06-09 20:16:47 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
2026-05-29 17:06:39 +00:00
)
2026-06-12 14:51:54 +00:00
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
2026-08-13 03:38:49 +00:00
currentConditionsEndpoint = "/conditions/current"
2026-08-13 00:15:26 +00:00
sourceSPCConvectiveOutlooks = config . MissingSourceSPCConvectiveOutlooks
2026-07-02 11:33:16 -05:00
2026-08-13 03:38:49 +00:00
defaultWarmupEndpoint = currentConditionsEndpoint
2026-07-02 11:33:16 -05:00
defaultWarmupAttempts = 3
defaultWarmupDelay = time . Second
defaultFetchAttempts = 2
defaultFetchRetryDelay = time . Second
2026-08-13 00:35:38 +00:00
maxResponseBodyBytes = 10 << 20
2026-06-12 14:51:54 +00:00
)
2026-08-13 00:35:38 +00:00
var errResponseBodyTooLarge = errors . New ( "response exceeds 10 MiB limit" )
2026-05-29 17:06:39 +00:00
type Client struct {
baseURL * url . URL
httpClient * http . Client
units string
format string
timezone string
precision int
missingSource config . MissingSourceConfig
now func ( ) time . Time
2026-07-02 11:33:16 -05:00
warmupEndpoint string
warmupAttempts int
warmupDelay time . Duration
fetchAttempts int
fetchRetryDelay time . Duration
2026-05-29 17:06:39 +00:00
}
type Option func ( * Client )
func WithHTTPClient ( httpClient * http . Client ) Option {
return func ( c * Client ) {
if httpClient != nil {
c . httpClient = httpClient
}
}
}
func WithClock ( now func ( ) time . Time ) Option {
return func ( c * Client ) {
if now != nil {
c . now = now
}
}
}
func New ( cfg config . Config , opts ... Option ) ( * Client , error ) {
if strings . TrimSpace ( cfg . WeatherAPI . BaseURL ) == "" {
return nil , fmt . Errorf ( "weather_api.base_url is required" )
}
baseURL , err := url . Parse ( cfg . WeatherAPI . BaseURL )
if err != nil || baseURL . Scheme == "" || baseURL . Host == "" {
return nil , fmt . Errorf ( "weather_api.base_url must be an absolute URL" )
}
2026-08-13 00:32:22 +00:00
if ! strings . EqualFold ( baseURL . Scheme , "http" ) && ! strings . EqualFold ( baseURL . Scheme , "https" ) {
return nil , fmt . Errorf ( "weather_api.base_url must use http or https" )
}
2026-05-29 17:06:39 +00:00
timeout := cfg . WeatherAPI . Timeout
if timeout <= 0 {
timeout = 10 * time . Second
}
client := & Client {
baseURL : baseURL ,
httpClient : & http . Client { Timeout : timeout } ,
units : cfg . WeatherAPI . Units ,
format : cfg . WeatherAPI . Format ,
timezone : cfg . WeatherAPI . Timezone ,
precision : cfg . WeatherAPI . Precision ,
missingSource : config . MissingSourceConfig {
Default : cfg . MissingSource . Default ,
Sources : cfg . MissingSource . Sources ,
} ,
2026-07-02 11:33:16 -05:00
now : time . Now ,
warmupEndpoint : defaultWarmupEndpoint ,
warmupAttempts : defaultWarmupAttempts ,
warmupDelay : defaultWarmupDelay ,
fetchAttempts : defaultFetchAttempts ,
fetchRetryDelay : defaultFetchRetryDelay ,
2026-05-29 17:06:39 +00:00
}
for _ , opt := range opts {
opt ( client )
}
return client , nil
}
2026-06-09 20:16:47 +00:00
func ( c * Client ) FetchBundle ( ctx context . Context ) ( * weatherdata . Bundle , error ) {
2026-08-13 03:38:49 +00:00
warmup , err := c . warmup ( ctx )
if err != nil {
2026-07-02 11:33:16 -05:00
return nil , err
}
2026-05-29 17:06:39 +00:00
fetchedAt := c . now ( )
builder := bundleBuilder {
2026-08-13 03:45:55 +00:00
client : c ,
bundle : & weatherdata . Bundle { FetchedAt : fetchedAt } ,
2026-05-29 17:06:39 +00:00
}
2026-08-13 03:45:55 +00:00
for _ , acquired := range builder . acquireSources ( ctx , warmup ) {
if err := ctx . Err ( ) ; err != nil {
return nil , fmt . Errorf ( "fetch weather API sources: %w" , err )
}
if err := builder . mergeSource ( acquired ) ; err != nil {
return nil , err
}
2026-06-12 14:51:54 +00:00
}
2026-05-29 17:06:39 +00:00
return builder . bundle , nil
}
type bundleBuilder struct {
2026-08-13 03:45:55 +00:00
client * Client
bundle * weatherdata . Bundle
2026-05-29 17:06:39 +00:00
}
2026-06-15 12:44:57 +00:00
type sourceRequest struct {
name string
endpoint string
query queryOptions
missingMessage string
required bool
decodeLabel string
}
type fetchedSource struct {
raw json . RawMessage
source weatherdata . Source
}
2026-08-13 03:38:49 +00:00
type warmupResponse struct {
endpoint string
requestURL * url . URL
body [ ] byte
fetchedAt time . Time
}
2026-08-13 03:45:55 +00:00
type sourceAcquisition struct {
request sourceRequest
fetched fetchedSource
err error
warmup warmupResponse
usesWarmup bool
}
func ( b * bundleBuilder ) acquireSources ( ctx context . Context , warmup warmupResponse ) [ ] sourceAcquisition {
sources := [ ] sourceAcquisition {
{ request : sourceRequest { name : config . MissingSourceObservations , endpoint : "/observations" , query : queryOptions { precision : true } , missingMessage : "observation data is missing" } } ,
{ request : currentConditionsRequest ( ) } ,
{ request : sourceRequest { name : "hourly" , endpoint : "/forecast/hourly" , query : queryOptions { precision : true , timezone : true } , missingMessage : "hourly forecast data is missing" , required : true , decodeLabel : "hourly forecast" } } ,
{ request : sourceRequest { name : config . MissingSourceNarrative , endpoint : "/forecast/narrative" , query : queryOptions { precision : true , timezone : true } , missingMessage : "narrative forecast data is missing" } } ,
{ request : sourceRequest { name : config . MissingSourceAlerts , endpoint : "/alerts/active" , query : queryOptions { allowNull : true } , missingMessage : "active alerts data is missing" } } ,
{ request : sourceRequest { name : config . MissingSourceDiscussion , endpoint : "/discussion" , query : queryOptions { timezone : true } , missingMessage : "forecast discussion data is missing" } } ,
{ request : sourceRequest { name : config . MissingSourceWeatherStory , endpoint : "/weatherstories/latest" , query : queryOptions { omitUnits : true } , missingMessage : "NWS weather story data is missing" } } ,
{ request : sourceRequest { name : sourceSPCConvectiveOutlooks , endpoint : convectiveOutlooksEndpoint , query : queryOptions { timezone : true , omitUnits : true } , missingMessage : "SPC convective outlook data is missing" } } ,
}
if warmup . endpoint == currentConditionsEndpoint {
sources [ 1 ] . warmup = warmup
sources [ 1 ] . usesWarmup = true
}
var group sync . WaitGroup
for i := range sources {
if sources [ i ] . usesWarmup {
continue
}
group . Add ( 1 )
go func ( index int ) {
defer group . Done ( )
request := sources [ index ] . request
raw , source , err := b . client . fetch ( ctx , request . name , request . endpoint , request . query )
sources [ index ] . fetched = fetchedSource { raw : raw , source : source }
sources [ index ] . err = err
} ( i )
}
group . Wait ( )
return sources
}
func ( b * bundleBuilder ) mergeSource ( acquired sourceAcquisition ) error {
switch acquired . request . name {
case config . MissingSourceObservations :
return b . fetchObservation ( acquired )
case config . MissingSourceCurrent :
return b . fetchCurrent ( acquired )
case "hourly" :
return b . fetchHourly ( acquired )
case config . MissingSourceNarrative :
return b . fetchNarrative ( acquired )
case config . MissingSourceAlerts :
return b . fetchAlerts ( acquired )
case config . MissingSourceDiscussion :
return b . fetchDiscussion ( acquired )
case config . MissingSourceWeatherStory :
return b . fetchWeatherStory ( acquired )
case sourceSPCConvectiveOutlooks :
return b . fetchSPCConvectiveOutlooks ( acquired )
default :
return fmt . Errorf ( "merge unknown weather source %q" , acquired . request . name )
}
}
func ( b * bundleBuilder ) fetchObservation ( acquired sourceAcquisition ) error {
2026-06-09 20:16:47 +00:00
var observation weatherdata . Observation
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & observation )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-05-29 17:06:39 +00:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-05-29 17:06:39 +00:00
source . IssuedAt = & observation . Timestamp
b . bundle . Observation = & observation
b . addSource ( source )
return nil
}
2026-08-13 03:38:49 +00:00
func currentConditionsRequest ( ) sourceRequest {
return sourceRequest {
2026-08-13 00:15:26 +00:00
name : config . MissingSourceCurrent ,
2026-08-13 03:38:49 +00:00
endpoint : currentConditionsEndpoint ,
2026-06-15 12:44:57 +00:00
query : queryOptions { precision : true } ,
missingMessage : "current conditions data is missing" ,
2026-08-13 03:38:49 +00:00
}
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchCurrent ( acquired sourceAcquisition ) error {
2026-08-13 03:38:49 +00:00
var current weatherdata . Current
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & current )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-05-29 17:06:39 +00:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-05-29 17:06:39 +00:00
b . bundle . Current = & current
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchHourly ( acquired sourceAcquisition ) error {
2026-06-09 20:16:47 +00:00
var hourly weatherdata . ForecastRun
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & hourly )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-05-29 17:06:39 +00:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-05-29 17:06:39 +00:00
if len ( hourly . Periods ) == 0 {
return fmt . Errorf ( "hourly forecast from %s contains no periods" , source . Endpoint )
}
2026-08-13 00:37:43 +00:00
for i , period := range hourly . Periods {
if ! period . HasUsableTimeBounds ( ) {
return fmt . Errorf ( "hourly forecast from %s has unusable time bounds for period %d" , source . Endpoint , i + 1 )
}
2026-08-13 00:48:40 +00:00
if ! period . HasValidPrecipitationProbability ( ) {
return fmt . Errorf ( "hourly forecast from %s has invalid precipitation probability for period %d" , source . Endpoint , i + 1 )
}
2026-08-13 00:37:43 +00:00
}
2026-05-29 17:06:39 +00:00
source . IssuedAt = & hourly . IssuedAt
source . UpdatedAt = hourly . UpdatedAt
b . bundle . Hourly = & hourly
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchNarrative ( acquired sourceAcquisition ) error {
2026-06-09 20:16:47 +00:00
var narrative weatherdata . ForecastRun
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & narrative )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-05-29 17:06:39 +00:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-05-29 17:06:39 +00:00
source . IssuedAt = & narrative . IssuedAt
source . UpdatedAt = narrative . UpdatedAt
b . bundle . Narrative = & narrative
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchAlerts ( acquired sourceAcquisition ) error {
fetched , ok , err := b . fetchSource ( acquired )
if err != nil || ! ok {
2026-05-29 17:06:39 +00:00
return err
}
2026-08-13 03:45:55 +00:00
raw , source := fetched . raw , fetched . source
2026-05-29 19:34:23 -05:00
if isJSONNull ( raw ) {
2026-08-13 04:17:05 +00:00
b . bundle . Alerts = & weatherdata . AlertRun { }
2026-05-29 19:34:23 -05:00
b . addSource ( source )
return nil
}
2026-06-09 20:16:47 +00:00
var alerts weatherdata . AlertRun
2026-05-29 17:06:39 +00:00
if err := decodeSource ( raw , & alerts ) ; err != nil {
2026-08-13 03:45:55 +00:00
return b . handleMalformed ( & source , err , acquired . request )
2026-05-29 17:06:39 +00:00
}
if alerts . AsOf != nil {
source . IssuedAt = alerts . AsOf
}
b . bundle . Alerts = & alerts
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchDiscussion ( acquired sourceAcquisition ) error {
2026-06-09 20:16:47 +00:00
var discussion weatherdata . Discussion
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & discussion )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-05-29 17:06:39 +00:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-05-29 17:06:39 +00:00
source . IssuedAt = & discussion . IssuedAt
source . UpdatedAt = discussion . UpdatedAt
b . bundle . Discussion = & discussion
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchWeatherStory ( acquired sourceAcquisition ) error {
2026-06-09 20:16:47 +00:00
var story weatherdata . WeatherStory
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & story )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-05-30 07:47:49 -05:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-08-13 00:53:31 +00:00
if ! story . HasUsableContent ( ) {
2026-08-13 03:45:55 +00:00
return b . handleMalformed ( & source , fmt . Errorf ( "weather story has no usable content" ) , acquired . request )
2026-08-13 00:53:31 +00:00
}
2026-05-30 07:47:49 -05:00
if ! story . StartTime . IsZero ( ) {
source . IssuedAt = & story . StartTime
}
source . UpdatedAt = story . UpdatedAt
b . bundle . WeatherStory = & story
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchSPCConvectiveOutlooks ( acquired sourceAcquisition ) error {
2026-06-12 14:51:54 +00:00
var run weatherdata . ConvectiveOutlookRun
2026-08-13 03:45:55 +00:00
fetched , ok , err := b . fetchDecodedSource ( acquired , & run )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return err
2026-06-12 14:51:54 +00:00
}
2026-06-15 12:44:57 +00:00
source := fetched . source
2026-06-12 14:51:54 +00:00
if run . IssuedAt != nil {
source . IssuedAt = run . IssuedAt
} else {
source . IssuedAt = run . AsOf
}
source . UpdatedAt = run . UpdatedAt
b . bundle . SPCConvectiveOutlooks = & run
b . addSource ( source )
return nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchDecodedSource ( acquired sourceAcquisition , target any ) ( fetchedSource , bool , error ) {
fetched , ok , err := b . fetchSource ( acquired )
2026-06-15 12:44:57 +00:00
if err != nil || ! ok {
return fetchedSource { } , false , err
}
2026-08-13 03:45:55 +00:00
return b . decodeFetchedSource ( fetched , acquired . request , target )
2026-08-13 03:38:49 +00:00
}
func ( b * bundleBuilder ) decodeFetchedSource ( fetched fetchedSource , request sourceRequest , target any ) ( fetchedSource , bool , error ) {
2026-06-15 12:44:57 +00:00
if err := decodeSource ( fetched . raw , target ) ; err != nil {
return fetchedSource { } , false , b . handleMalformed ( & fetched . source , err , request )
}
return fetched , true , nil
}
2026-08-13 03:45:55 +00:00
func ( b * bundleBuilder ) fetchSource ( acquired sourceAcquisition ) ( fetchedSource , bool , error ) {
if acquired . usesWarmup {
raw , source , err := b . client . decodeSourceResponse ( acquired . request . name , acquired . request . endpoint , acquired . request . query , acquired . warmup . requestURL , acquired . warmup . body , acquired . warmup . fetchedAt )
if err != nil {
return fetchedSource { } , false , err
}
acquired . fetched = fetchedSource { raw : raw , source : source }
} else if acquired . err != nil {
return fetchedSource { } , false , acquired . err
2026-06-15 12:44:57 +00:00
}
2026-08-13 03:45:55 +00:00
if acquired . fetched . raw == nil {
return fetchedSource { } , false , b . handleMissing ( & acquired . fetched . source , acquired . request . missingMessage , acquired . request . required )
2026-06-15 12:44:57 +00:00
}
2026-08-13 03:45:55 +00:00
return acquired . fetched , true , nil
2026-06-15 12:44:57 +00:00
}
2026-06-09 20:16:47 +00:00
func ( b * bundleBuilder ) handleMissing ( source * weatherdata . Source , message string , required bool ) error {
2026-05-29 17:06:39 +00:00
source . Missing = true
if required {
return fmt . Errorf ( "%s from %s is required" , message , source . Endpoint )
}
return b . applyMissingPolicy ( source , "missing_source" , message )
}
2026-06-15 12:44:57 +00:00
func ( b * bundleBuilder ) handleMalformed ( source * weatherdata . Source , err error , request sourceRequest ) error {
if request . required {
label := request . name
if request . decodeLabel != "" {
label = request . decodeLabel
}
return fmt . Errorf ( "decode %s from %s: %w" , label , source . Endpoint , err )
2026-05-29 17:06:39 +00:00
}
source . Missing = true
return b . applyMissingPolicy ( source , "malformed_source" , fmt . Sprintf ( "malformed %s data: %v" , source . Name , err ) )
}
2026-06-09 20:16:47 +00:00
func ( b * bundleBuilder ) applyMissingPolicy ( source * weatherdata . Source , code string , message string ) error {
2026-05-29 17:06:39 +00:00
policy := b . client . policyFor ( source . Name )
if policy == config . MissingSourceError {
return fmt . Errorf ( "%s: %s" , source . Name , message )
}
if policy == config . MissingSourceWarn {
2026-06-09 20:16:47 +00:00
warning := weatherdata . SourceWarning {
2026-05-29 17:06:39 +00:00
Source : source . Name ,
Code : code ,
Severity : "warning" ,
Message : message ,
Endpoint : source . Endpoint ,
CompletenessImpact : "source omitted from bundle" ,
}
source . Warnings = append ( source . Warnings , warning )
b . bundle . Warnings = append ( b . bundle . Warnings , warning )
}
b . addSource ( * source )
return nil
}
2026-06-09 20:16:47 +00:00
func ( b * bundleBuilder ) addSource ( source weatherdata . Source ) {
2026-05-29 17:06:39 +00:00
b . bundle . Sources = append ( b . bundle . Sources , source )
}
func ( c * Client ) policyFor ( source string ) config . MissingSourcePolicy {
if policy , ok := c . missingSource . Sources [ source ] ; ok {
return policy
}
return c . missingSource . Default
}
type queryOptions struct {
precision bool
timezone bool
2026-05-29 19:34:23 -05:00
allowNull bool
2026-05-30 07:47:49 -05:00
omitUnits bool
2026-05-29 17:06:39 +00:00
}
type envelope struct {
Data json . RawMessage ` json:"data" `
}
2026-06-09 20:16:47 +00:00
func ( c * Client ) fetch ( ctx context . Context , sourceName string , endpoint string , opts queryOptions ) ( json . RawMessage , weatherdata . Source , error ) {
2026-07-02 11:33:16 -05:00
reqURL , body , err := c . fetchHTTP ( ctx , endpoint , opts )
2026-05-29 17:06:39 +00:00
if err != nil {
2026-07-02 11:33:16 -05:00
return nil , weatherdata . Source { } , err
2026-05-29 17:06:39 +00:00
}
2026-08-13 03:38:49 +00:00
return c . decodeSourceResponse ( sourceName , endpoint , opts , reqURL , body , c . now ( ) )
}
2026-05-29 17:06:39 +00:00
2026-08-13 03:38:49 +00:00
func ( c * Client ) decodeSourceResponse ( sourceName string , endpoint string , opts queryOptions , reqURL * url . URL , body [ ] byte , fetchedAt time . Time ) ( json . RawMessage , weatherdata . Source , error ) {
2026-05-29 17:06:39 +00:00
var env envelope
if err := json . Unmarshal ( body , & env ) ; err != nil {
2026-06-09 20:16:47 +00:00
return nil , weatherdata . Source { } , fmt . Errorf ( "decode %s envelope: %w" , endpoint , err )
2026-05-29 17:06:39 +00:00
}
2026-06-09 20:16:47 +00:00
source := weatherdata . Source {
2026-05-29 17:06:39 +00:00
Name : sourceName ,
Endpoint : endpoint ,
Query : queryMap ( reqURL . Query ( ) ) ,
2026-08-13 03:38:49 +00:00
FetchedAt : fetchedAt ,
2026-05-29 17:06:39 +00:00
}
2026-05-29 19:34:23 -05:00
if len ( env . Data ) == 0 || ( isJSONNull ( env . Data ) && ! opts . allowNull ) {
2026-05-29 17:06:39 +00:00
source . Missing = true
return nil , source , nil
}
hash , err := sourceHash ( env . Data )
if err != nil {
return env . Data , source , nil
}
source . DataSHA256 = hash
return env . Data , source , nil
}
2026-08-13 03:38:49 +00:00
func ( c * Client ) warmup ( ctx context . Context ) ( warmupResponse , error ) {
2026-07-02 11:33:16 -05:00
endpoint := c . warmupEndpoint
if strings . TrimSpace ( endpoint ) == "" {
endpoint = defaultWarmupEndpoint
}
attempts := positiveAttemptCount ( c . warmupAttempts )
var lastErr error
2026-08-13 00:32:22 +00:00
var lastRetryable bool
2026-07-02 11:33:16 -05:00
for attempt := 1 ; attempt <= attempts ; attempt ++ {
if err := ctx . Err ( ) ; err != nil {
2026-08-13 03:38:49 +00:00
return warmupResponse { } , fmt . Errorf ( "warm up weather API via %s: %w" , endpoint , err )
2026-07-02 11:33:16 -05:00
}
2026-08-13 03:38:49 +00:00
reqURL , body , err := c . warmupOnce ( ctx , endpoint )
if err != nil {
2026-07-02 11:33:16 -05:00
lastErr = err
2026-08-13 00:32:22 +00:00
lastRetryable = isRetryableRequestError ( err )
2026-07-02 11:33:16 -05:00
} else {
2026-08-13 03:38:49 +00:00
return warmupResponse { endpoint : endpoint , requestURL : reqURL , body : body , fetchedAt : c . now ( ) } , nil
2026-07-02 11:33:16 -05:00
}
2026-08-13 00:32:22 +00:00
if ! lastRetryable || attempt == attempts {
2026-07-02 11:33:16 -05:00
break
}
if err := waitForRetry ( ctx , c . warmupDelay ) ; err != nil {
2026-08-13 03:38:49 +00:00
return warmupResponse { } , fmt . Errorf ( "warm up weather API via %s after %d attempt(s): %w" , endpoint , attempt , err )
2026-07-02 11:33:16 -05:00
}
}
2026-08-13 00:32:22 +00:00
if ! lastRetryable {
2026-08-13 03:38:49 +00:00
return warmupResponse { } , lastErr
2026-08-13 00:32:22 +00:00
}
2026-08-13 03:38:49 +00:00
return warmupResponse { } , fmt . Errorf ( "warm up weather API via %s failed after %d attempts: %w" , endpoint , attempts , lastErr )
2026-07-02 11:33:16 -05:00
}
2026-08-13 03:38:49 +00:00
func ( c * Client ) warmupOnce ( ctx context . Context , endpoint string ) ( * url . URL , [ ] byte , error ) {
return c . fetchHTTPOnce ( ctx , endpoint , queryOptions { precision : true } )
2026-07-02 11:33:16 -05:00
}
func ( c * Client ) fetchHTTP ( ctx context . Context , endpoint string , opts queryOptions ) ( * url . URL , [ ] byte , error ) {
attempts := positiveAttemptCount ( c . fetchAttempts )
var lastErr error
var lastRetryable bool
for attempt := 1 ; attempt <= attempts ; attempt ++ {
if err := ctx . Err ( ) ; err != nil {
return nil , nil , fmt . Errorf ( "fetch %s: %w" , endpoint , err )
}
reqURL , body , err := c . fetchHTTPOnce ( ctx , endpoint , opts )
if err == nil {
return reqURL , body , nil
}
lastErr = err
lastRetryable = isRetryableRequestError ( err )
if ! lastRetryable || attempt == attempts {
break
}
if err := waitForRetry ( ctx , c . fetchRetryDelay ) ; err != nil {
return nil , nil , fmt . Errorf ( "fetch %s retry delay after attempt %d: %w" , endpoint , attempt , err )
}
}
if lastRetryable {
return nil , nil , fmt . Errorf ( "fetch %s failed after %d attempts: %w" , endpoint , attempts , lastErr )
}
return nil , nil , lastErr
}
func ( c * Client ) fetchHTTPOnce ( ctx context . Context , endpoint string , opts queryOptions ) ( * url . URL , [ ] byte , error ) {
reqURL := c . endpointURL ( endpoint , opts )
req , err := http . NewRequestWithContext ( ctx , http . MethodGet , reqURL . String ( ) , nil )
if err != nil {
return nil , nil , 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 reqURL , nil , err
}
return reqURL , nil , retryableRequestError { err : err }
}
defer resp . Body . Close ( )
2026-08-13 00:35:38 +00:00
body , err := readResponseBody ( resp . Body )
2026-07-02 11:33:16 -05:00
if err != nil {
2026-08-13 00:35:38 +00:00
return reqURL , nil , responseReadError ( ctx , endpoint , err )
2026-07-02 11:33:16 -05:00
}
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
2026-08-13 00:35:38 +00:00
err := fmt . Errorf ( "fetch %s: unexpected HTTP status %d" , endpoint , resp . StatusCode )
2026-07-02 11:33:16 -05:00
if isRetryableHTTPStatus ( resp . StatusCode ) {
return reqURL , nil , retryableRequestError { err : err }
}
return reqURL , nil , err
}
return reqURL , body , nil
}
2026-08-13 00:35:38 +00:00
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 }
}
2026-07-02 11:33:16 -05:00
type retryableRequestError struct {
err error
}
func ( e retryableRequestError ) Error ( ) string {
return e . err . Error ( )
}
func ( e retryableRequestError ) Unwrap ( ) error {
return e . err
}
func isRetryableRequestError ( err error ) bool {
_ , ok := err . ( retryableRequestError )
return ok
}
func isRetryableHTTPStatus ( status int ) bool {
switch status {
case http . StatusRequestTimeout ,
http . StatusTooManyRequests ,
http . StatusInternalServerError ,
http . StatusBadGateway ,
http . StatusServiceUnavailable ,
http . StatusGatewayTimeout :
return true
default :
return false
}
}
func waitForRetry ( ctx context . Context , delay time . Duration ) error {
if delay <= 0 {
return ctx . Err ( )
}
timer := time . NewTimer ( delay )
defer timer . Stop ( )
select {
case <- ctx . Done ( ) :
return ctx . Err ( )
case <- timer . C :
return nil
}
}
func positiveAttemptCount ( attempts int ) int {
if attempts < 1 {
return 1
}
return attempts
}
2026-05-29 19:34:23 -05:00
func isJSONNull ( raw json . RawMessage ) bool {
return bytes . Equal ( bytes . TrimSpace ( raw ) , [ ] byte ( "null" ) )
}
2026-05-29 17:06:39 +00:00
func ( c * Client ) endpointURL ( endpoint string , opts queryOptions ) * url . URL {
reqURL := * c . baseURL
reqURL . Path = path . Join ( c . baseURL . Path , endpoint )
query := reqURL . Query ( )
query . Set ( "format" , c . format )
2026-05-30 07:47:49 -05:00
if ! opts . omitUnits {
query . Set ( "units" , c . units )
}
2026-05-29 17:06:39 +00:00
if opts . precision {
query . Set ( "precision" , strconv . Itoa ( c . precision ) )
}
if opts . timezone {
query . Set ( "tz" , c . timezone )
}
reqURL . RawQuery = query . Encode ( )
return & reqURL
}
func queryMap ( values url . Values ) map [ string ] string {
if len ( values ) == 0 {
return nil
}
out := make ( map [ string ] string , len ( values ) )
for key , value := range values {
if len ( value ) > 0 {
out [ key ] = value [ 0 ]
}
}
return out
}
func decodeSource ( raw json . RawMessage , target any ) error {
if err := json . Unmarshal ( raw , target ) ; err != nil {
return err
}
return nil
}
func sourceHash ( raw json . RawMessage ) ( string , error ) {
var compact bytes . Buffer
if err := json . Compact ( & compact , raw ) ; err != nil {
return "" , err
}
sum := sha256 . Sum256 ( compact . Bytes ( ) )
return hex . EncodeToString ( sum [ : ] ) , nil
}