Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/samgov/client.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 1675031db74316e100782d063e9eda1a597fe3a1 |
| Branch | workstream-a-opportunity-intelligence |
| Path or archive member | internal/platform/samgov/client.go |
| Original SHA-256 | 5e5a7bb9a29ae53b9e32b51413fc6de138fc57c48bb2fcfb216ec35737c552b0 |
| Git blob SHA | d5959abe2ab4f59f42f8e014f93d415ef48fb3e8 |
| Reading-copy SHA-256 | 5e5a7bb9a29ae53b9e32b51413fc6de138fc57c48bb2fcfb216ec35737c552b0 |
Every recorded occurrence
These 7 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
Recovered source transcript
Download complete source text ↓221 lines · 7.5 KB
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
}