package samgov import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "strconv" "strings" "time" ) // Base endpoints. Prod is the default; the dev alpha (prodlike) is selectable via SAM_API_BASE. const ( BaseProd = "https://api.sam.gov/prod/opportunities/v2/search" BaseAlpha = "https://api-alpha.sam.gov/prodlike/opportunities/v2/search" ) const ( maxLimit = 1000 // SAM hard cap samDateLayout = "01/02/2006" throttleCap = 1024 // max body bytes captured from a 429 pageGap = 250 * time.Millisecond maxWindow = 364 * 24 * time.Hour // SAM rejects 365+ civil days ) // civilDate truncates t to midnight UTC of its calendar date (SAM compares date parts only). func civilDate(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } // ErrThrottled is the sentinel for a SAM 429 (daily quota). Detect with errors.Is(err, ErrThrottled). var ErrThrottled = errors.New("samgov: rate limited (429)") // ThrottleError carries the Retry-After hint (SAM points it at midnight UTC). It satisfies // errors.Is(err, ErrThrottled). type ThrottleError struct { RetryAfter string Body string } func (e *ThrottleError) Error() string { if e.RetryAfter != "" { return fmt.Sprintf("samgov: rate limited (429), retry after %s", e.RetryAfter) } return "samgov: rate limited (429)" } func (e *ThrottleError) Is(target error) bool { return target == ErrThrottled } // Client is a typed SAM search client over the built-in net/http. The api_key is held here and only // ever added as a query parameter, it is never placed in logs or error strings. type Client struct { base string key string http *http.Client } // New builds a client. An empty base defaults to prod. func New(base, key string) *Client { if strings.TrimSpace(base) == "" { base = BaseProd } return &Client{base: base, key: key, http: &http.Client{Timeout: 45 * time.Second}} } // SearchParams are the supported filters. NAICS is one 6-digit code per call (ncode). The window // [PostedFrom, PostedTo] may not exceed one year. type SearchParams struct { PostedFrom time.Time PostedTo time.Time NAICS string // ncode State string // place-of-performance state SetAside string // typeOfSetAside (combining with ncode can return zero, the sync omits it) PType string // notice type: o,k,p,r,a Limit int // 1..1000 Offset int } // Search fetches a single page. It enforces the one-year window BEFORE any network call and maps a // 429 to *ThrottleError. func (c *Client) Search(ctx context.Context, p SearchParams) (SearchResult, error) { if p.PostedFrom.IsZero() || p.PostedTo.IsZero() { return SearchResult{}, errors.New("samgov: postedFrom and postedTo are required") } // SAM compares the date parts only (MM/dd/yyyy) and rejects a span of 365 civil days or more with a // 400 ("must be 1 year(s) apart"), so the effective maximum is 364 days. Compare on civil dates so a // wall-clock time or a DST boundary cannot tip a valid request over the edge. fromDay := civilDate(p.PostedFrom) toDay := civilDate(p.PostedTo) if toDay.Before(fromDay) { return SearchResult{}, errors.New("samgov: postedTo is before postedFrom") } if toDay.Sub(fromDay) > maxWindow { return SearchResult{}, fmt.Errorf("samgov: window %s..%s exceeds SAM's maximum of 364 days", fromDay.Format("2006-01-02"), toDay.Format("2006-01-02")) } limit := p.Limit if limit < 1 { limit = 1 } if limit > maxLimit { limit = maxLimit } q := url.Values{} q.Set("api_key", c.key) q.Set("postedFrom", p.PostedFrom.Format(samDateLayout)) q.Set("postedTo", p.PostedTo.Format(samDateLayout)) q.Set("limit", strconv.Itoa(limit)) q.Set("offset", strconv.Itoa(p.Offset)) if p.NAICS != "" { q.Set("ncode", p.NAICS) } if p.State != "" { q.Set("state", p.State) } if p.SetAside != "" { q.Set("typeOfSetAside", p.SetAside) } if p.PType != "" { q.Set("ptype", p.PType) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"?"+q.Encode(), nil) if err != nil { // A parse error would echo the full URL (incl. ?api_key=…), keep it generic [review SEC-1]. return SearchResult{}, errors.New("samgov: failed to build request") } req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { // *url.Error.Error() embeds the full request URL incl. ?api_key=…. NEVER wrap it: ue.Err is the // transport-level cause and carries host:port at most, never the query string [review SEC-1]. var ue *url.Error if errors.As(err, &ue) { return SearchResult{}, fmt.Errorf("samgov: %s request failed: %v", ue.Op, ue.Err) } return SearchResult{}, errors.New("samgov: request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusTooManyRequests { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, throttleCap)) return SearchResult{}, &ThrottleError{RetryAfter: resp.Header.Get("Retry-After"), Body: string(snippet)} } if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, throttleCap)) return SearchResult{}, fmt.Errorf("samgov: status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) } var page rawPage if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { return SearchResult{}, fmt.Errorf("samgov: decode response: %w", err) } recs := make([]Record, 0, len(page.OpportunitiesData)) skipped := 0 for _, raw := range page.OpportunitiesData { var op Opportunity if err := json.Unmarshal(raw, &op); err != nil { // A type regression on ONE notice must not discard the whole page and halt pagination for // this NAICS. Record.Raw still holds the verbatim JSON downstream, so resilience matches the // documented promise; count the skip and continue. Do not log the notice (it may carry CUI). skipped++ continue } recs = append(recs, Record{Op: op, Raw: raw}) } if skipped > 0 { log.Printf("samgov: skipped %d malformed notice(s) on this page", skipped) } return SearchResult{ TotalRecords: page.TotalRecords, Limit: page.Limit, Offset: page.Offset, Fetched: len(page.OpportunitiesData), Records: recs, }, nil } // SearchAll paginates by offset until every record is retrieved or pageCap pages have been fetched. // It returns whatever it has gathered so far alongside any error, so a 429 mid-run yields the // partial set plus ErrThrottled, letting the caller persist progress and stop gracefully. capped // reports whether the page cap (not exhaustion) ended the run. func (c *Client) SearchAll(ctx context.Context, p SearchParams, pageCap int) (recs []Record, total int, capped bool, err error) { if pageCap < 1 { pageCap = 1 } if p.Limit < 1 || p.Limit > maxLimit { p.Limit = maxLimit } offset := p.Offset for page := 0; page < pageCap; page++ { pp := p pp.Offset = offset res, e := c.Search(ctx, pp) if e != nil { return recs, total, false, e // includes *ThrottleError; caller keeps recs gathered so far } total = res.TotalRecords recs = append(recs, res.Records...) // Advance by what SAM RETURNED (Fetched), not by what we kept, so a skipped malformed notice does // not re fetch or stall pagination. A page that decodes entirely empty still advances. offset += res.Fetched if res.Fetched == 0 || offset >= total { return recs, total, false, nil // exhausted } select { case <-ctx.Done(): return recs, total, false, ctx.Err() case <-time.After(pageGap): // be gentle, never hammer the API } } // Ran out of pages before exhausting the result set. return recs, total, offset < total, nil }