package routing import ( "context" "encoding/json" "fmt" "net/http" "net/url" "sync" "time" ) // Fetcher reads /pass-rate from the brain pod with a per-skill TTL cache. type Fetcher struct { BaseURL string Window string TTL time.Duration HTTP *http.Client mu sync.Mutex cache map[string]cachedRate } type cachedRate struct { value *float64 at time.Time } type passRateResponse struct { PassRate *float64 `json:"pass_rate"` } // NewFetcher returns a Fetcher that calls baseURL + /pass-rate with the // given window string. If ttl is zero, defaults to 60 seconds. The HTTP // client uses a 1-second total timeout. func NewFetcher(baseURL, window string, ttl time.Duration) *Fetcher { if ttl == 0 { ttl = 60 * time.Second } return &Fetcher{ BaseURL: baseURL, Window: window, TTL: ttl, HTTP: &http.Client{Timeout: time.Second}, cache: make(map[string]cachedRate), } } // Get returns the pass rate for the named skill, or nil if no data exists, // or an error if the brain is unreachable. Caches successful results. func (f *Fetcher) Get(ctx context.Context, skill string) (*float64, error) { f.mu.Lock() if c, ok := f.cache[skill]; ok && time.Since(c.at) < f.TTL { v := c.value f.mu.Unlock() return v, nil } f.mu.Unlock() u := fmt.Sprintf("%s/pass-rate?skill=%s&window=%s", f.BaseURL, url.QueryEscape(skill), url.QueryEscape(f.Window)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, fmt.Errorf("passrate: build request: %w", err) } resp, err := f.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("passrate: request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("passrate: server returned status %d", resp.StatusCode) } var body passRateResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, fmt.Errorf("passrate: decode: %w", err) } f.mu.Lock() f.cache[skill] = cachedRate{value: body.PassRate, at: time.Now()} f.mu.Unlock() return body.PassRate, nil }