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 | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/samgov/client.go |
| Original SHA-256 | 0ed63d358d47d084be54528762c9e07e3fe5aaca385925a7cb6dd3458429573e |
| Git blob SHA | 81e2f35c5ace0f52a2356c50c8bee31b6455b4d5 |
| Reading-copy SHA-256 | 0ed63d358d47d084be54528762c9e07e3fe5aaca385925a7cb6dd3458429573e |
Every recorded occurrence
These 31 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 | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
| Janady13/missionmatch / internal/platform/samgov/client.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | 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 during pagination 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
}