// Package bls is a typed client for the Bureau of Labor Statistics public API v2, used to pull the // OEWS regional wage baseline for the Galapagos occupations. It has no database dependency: it fetches // the latest annual wage per series; the rates sync (cmd/rates-sync) writes the cache. The // registrationKey is read from env on the server and sent only in the request body, never logged. package bls import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // BaseV2 is the BLS public API v2 timeseries endpoint. const BaseV2 = "https://api.bls.gov/publicAPI/v2/timeseries/data/" const maxBatch = 50 // BLS allows up to 50 series per request with a registered key // ErrThrottled is returned when the BLS daily request threshold is reached. var ErrThrottled = errors.New("bls: daily request threshold reached") // Client calls the BLS API. type Client struct { key string base string http *http.Client } // New builds a client. An empty base uses the v2 endpoint. func New(key, base string) *Client { if strings.TrimSpace(base) == "" { base = BaseV2 } return &Client{key: key, base: base, http: &http.Client{Timeout: 45 * time.Second}} } // Series is one returned series with its latest data point. type Series struct { ID string Year string Value string } // FetchLatest pulls the latest data point for each series id, batching to respect the API limit. func (c *Client) FetchLatest(ctx context.Context, seriesIDs []string) ([]Series, error) { var out []Series for start := 0; start < len(seriesIDs); start += maxBatch { end := start + maxBatch if end > len(seriesIDs) { end = len(seriesIDs) } batch, err := c.fetchBatch(ctx, seriesIDs[start:end]) if err != nil { return out, err } out = append(out, batch...) } return out, nil } func (c *Client) fetchBatch(ctx context.Context, ids []string) ([]Series, error) { payload, err := json.Marshal(map[string]any{ "seriesid": ids, "latest": true, "registrationKey": c.key, }) if err != nil { return nil, fmt.Errorf("bls: marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base, bytes.NewReader(payload)) if err != nil { return nil, errors.New("bls: failed to build request") } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, errors.New("bls: request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusTooManyRequests { return nil, ErrThrottled } if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("bls: status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) } var r blsResponse if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("bls: decode response: %w", err) } if r.Status != "REQUEST_SUCCEEDED" { msg := strings.ToLower(strings.Join(r.Message, " ")) if strings.Contains(msg, "threshold") || strings.Contains(msg, "daily") || strings.Contains(msg, "limit") { return nil, ErrThrottled } return nil, fmt.Errorf("bls: request not processed: %s", strings.Join(r.Message, "; ")) } out := make([]Series, 0, len(r.Results.Series)) for _, s := range r.Results.Series { if len(s.Data) == 0 { continue // no data published for this series } d := s.Data[0] // latest=true puts the most recent point first out = append(out, Series{ID: s.SeriesID, Year: d.Year, Value: d.Value}) } return out, nil } type blsResponse struct { Status string `json:"status"` Message []string `json:"message"` Results struct { Series []struct { SeriesID string `json:"seriesID"` Data []struct { Year string `json:"year"` Period string `json:"period"` Value string `json:"value"` } `json:"data"` } `json:"series"` } `json:"Results"` }