gave the server a breaker so a sulking upstream can't drag it down
osv.dev or repology going dark used to mean every check sat there burning its 30s timeout, one after another. now there's a breaker (ported the agent's, it's already proven) wrapping both: - osv: one breaker over the batch + single-query paths. trips after 5 fails in a minute, fails open while tripped — an unreachable advisory feed never blocks a patch. that's the whole sovereignty bet - repology: same deal, best-effort, 404 doesn't count against it - both visible at /health/tasks so you can see them trip and heal db-pool shedding (503 + retry-after) is the other half — left it for later, the pool bump + bounded background already took most of that pressure off. race detector's clean.
This commit is contained in:
parent
d25f6ea030
commit
e99ad6e8c7
5 changed files with 403 additions and 45 deletions
|
|
@ -913,6 +913,10 @@ func main() {
|
|||
c.JSON(200, gin.H{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"taskrunner": bgRunner.Snapshot(),
|
||||
"breakers": gin.H{
|
||||
"osv": services.OSVBreakerStats(),
|
||||
"repology": upstream.RepologyBreakerStats(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
200
server/internal/circuitbreaker/circuitbreaker.go
Normal file
200
server/internal/circuitbreaker/circuitbreaker.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Package circuitbreaker is the server-side circuit breaker (SCALE-001 S8).
|
||||
// It mirrors the agent's breaker (agent/internal/circuitbreaker) — same states,
|
||||
// config, and semantics — so the two halves of RedFlag behave identically. The
|
||||
// server uses it to wrap fragile outbound dependencies (OSV.dev, repology.org):
|
||||
// when an upstream is failing or slow, the breaker opens and calls fail fast
|
||||
// instead of piling goroutines and timing out one by one. Callers decide the
|
||||
// fail direction; the supply-chain path fails OPEN (sovereignty) on an open
|
||||
// breaker, exactly as it does on a transport error.
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State represents the circuit breaker state.
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateClosed State = iota // Normal operation
|
||||
StateOpen // Failing fast
|
||||
StateHalfOpen // Probing recovery
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateClosed:
|
||||
return "closed"
|
||||
case StateOpen:
|
||||
return "open"
|
||||
case StateHalfOpen:
|
||||
return "half-open"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds circuit breaker configuration.
|
||||
type Config struct {
|
||||
FailureThreshold int // Failures within the window before opening
|
||||
FailureWindow time.Duration // Window over which failures are counted
|
||||
OpenDuration time.Duration // How long the circuit stays open before probing
|
||||
HalfOpenAttempts int // Consecutive successes needed to close from half-open
|
||||
}
|
||||
|
||||
// CircuitBreaker implements the breaker pattern for a single dependency.
|
||||
type CircuitBreaker struct {
|
||||
name string
|
||||
config Config
|
||||
|
||||
mu sync.RWMutex
|
||||
state State
|
||||
failures []time.Time
|
||||
consecutiveSuccess int
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
// New creates a closed circuit breaker.
|
||||
func New(name string, config Config) *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
name: name,
|
||||
config: config,
|
||||
state: StateClosed,
|
||||
failures: make([]time.Time, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Call runs fn under breaker protection. When the breaker is open it returns the
|
||||
// breaker error without invoking fn.
|
||||
func (cb *CircuitBreaker) Call(fn func() error) error {
|
||||
if err := cb.beforeCall(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := fn()
|
||||
cb.afterCall(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) beforeCall() error {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
return nil
|
||||
case StateOpen:
|
||||
if time.Since(cb.openedAt) >= cb.config.OpenDuration {
|
||||
cb.state = StateHalfOpen
|
||||
cb.consecutiveSuccess = 0
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("circuit breaker [%s] is OPEN (will retry at %s)",
|
||||
cb.name, cb.openedAt.Add(cb.config.OpenDuration).Format("15:04:05"))
|
||||
case StateHalfOpen:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown circuit breaker state")
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) afterCall(err error) {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if err != nil {
|
||||
cb.recordFailure(now)
|
||||
if cb.state == StateHalfOpen {
|
||||
cb.state = StateOpen
|
||||
cb.openedAt = now
|
||||
cb.consecutiveSuccess = 0
|
||||
return
|
||||
}
|
||||
if cb.shouldOpen(now) {
|
||||
cb.state = StateOpen
|
||||
cb.openedAt = now
|
||||
cb.consecutiveSuccess = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch cb.state {
|
||||
case StateHalfOpen:
|
||||
cb.consecutiveSuccess++
|
||||
if cb.consecutiveSuccess >= cb.config.HalfOpenAttempts {
|
||||
cb.state = StateClosed
|
||||
cb.failures = make([]time.Time, 0)
|
||||
cb.consecutiveSuccess = 0
|
||||
}
|
||||
case StateClosed:
|
||||
cb.cleanupOldFailures(now)
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) recordFailure(now time.Time) {
|
||||
cb.failures = append(cb.failures, now)
|
||||
cb.cleanupOldFailures(now)
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) cleanupOldFailures(now time.Time) {
|
||||
cutoff := now.Add(-cb.config.FailureWindow)
|
||||
valid := make([]time.Time, 0, len(cb.failures))
|
||||
for _, t := range cb.failures {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
cb.failures = valid
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) shouldOpen(now time.Time) bool {
|
||||
cb.cleanupOldFailures(now)
|
||||
return len(cb.failures) >= cb.config.FailureThreshold
|
||||
}
|
||||
|
||||
// State returns the current state (thread-safe).
|
||||
func (cb *CircuitBreaker) State() State {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
return cb.state
|
||||
}
|
||||
|
||||
// Stats is a point-in-time view for health/metrics (OBS-001).
|
||||
type Stats struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
RecentFailures int `json:"recent_failures"`
|
||||
ConsecutiveSuccess int `json:"consecutive_success"`
|
||||
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
||||
}
|
||||
|
||||
// GetStats returns the current breaker statistics (thread-safe).
|
||||
func (cb *CircuitBreaker) GetStats() Stats {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
|
||||
stats := Stats{
|
||||
Name: cb.name,
|
||||
State: cb.state.String(),
|
||||
RecentFailures: len(cb.failures),
|
||||
ConsecutiveSuccess: cb.consecutiveSuccess,
|
||||
}
|
||||
if cb.state == StateOpen && !cb.openedAt.IsZero() {
|
||||
next := cb.openedAt.Add(cb.config.OpenDuration)
|
||||
stats.NextAttempt = &next
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// Reset forces the breaker back to closed (manual recovery / tests).
|
||||
func (cb *CircuitBreaker) Reset() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
cb.state = StateClosed
|
||||
cb.failures = make([]time.Time, 0)
|
||||
cb.consecutiveSuccess = 0
|
||||
cb.openedAt = time.Time{}
|
||||
}
|
||||
91
server/internal/circuitbreaker/circuitbreaker_test.go
Normal file
91
server/internal/circuitbreaker/circuitbreaker_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testConfig() Config {
|
||||
return Config{
|
||||
FailureThreshold: 3,
|
||||
FailureWindow: time.Second,
|
||||
OpenDuration: 50 * time.Millisecond,
|
||||
HalfOpenAttempts: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosedStaysClosedOnSuccess(t *testing.T) {
|
||||
cb := New("t", testConfig())
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := cb.Call(func() error { return nil }); err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
if cb.State() != StateClosed {
|
||||
t.Fatalf("state=%s, want closed", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpensAfterThresholdAndFailsFast(t *testing.T) {
|
||||
cb := New("t", testConfig())
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = cb.Call(func() error { return boom })
|
||||
}
|
||||
if cb.State() != StateOpen {
|
||||
t.Fatalf("state=%s, want open after %d failures", cb.State(), 3)
|
||||
}
|
||||
|
||||
// While open, fn must not run and Call returns the breaker error.
|
||||
ran := false
|
||||
err := cb.Call(func() error { ran = true; return nil })
|
||||
if ran {
|
||||
t.Error("fn ran while breaker open — should fail fast")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("expected breaker error while open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHalfOpenRecoversToClosed(t *testing.T) {
|
||||
cb := New("t", testConfig())
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = cb.Call(func() error { return boom })
|
||||
}
|
||||
if cb.State() != StateOpen {
|
||||
t.Fatalf("precondition: want open, got %s", cb.State())
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond) // past OpenDuration → next call probes (half-open)
|
||||
|
||||
// HalfOpenAttempts=2 consecutive successes needed to close.
|
||||
if err := cb.Call(func() error { return nil }); err != nil {
|
||||
t.Fatalf("probe call err: %v", err)
|
||||
}
|
||||
if err := cb.Call(func() error { return nil }); err != nil {
|
||||
t.Fatalf("second probe err: %v", err)
|
||||
}
|
||||
if cb.State() != StateClosed {
|
||||
t.Fatalf("state=%s, want closed after recovery", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHalfOpenReopensOnFailure(t *testing.T) {
|
||||
cb := New("t", testConfig())
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = cb.Call(func() error { return boom })
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond) // → half-open on next call
|
||||
|
||||
_ = cb.Call(func() error { return boom }) // a failure in half-open reopens
|
||||
if cb.State() != StateOpen {
|
||||
t.Fatalf("state=%s, want open (half-open failure must reopen)", cb.State())
|
||||
}
|
||||
st := cb.GetStats()
|
||||
if st.State != "open" || st.NextAttempt == nil {
|
||||
t.Errorf("stats=%+v, want open with NextAttempt set", st)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ package services
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
|
||||
"github.com/Fimeg/RedFlag/server/internal/httpx"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
|
@ -138,6 +140,22 @@ func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
|
|||
|
||||
var osvHTTPClient = httpx.NewClient(30 * time.Second)
|
||||
|
||||
// osvBreaker wraps OSV.dev calls (SCALE-001 S8). Both the batch and single-query
|
||||
// paths hit api.osv.dev, so they share one breaker: when OSV is down or slow,
|
||||
// the breaker opens and subsequent checks fail fast instead of each timing out.
|
||||
// Callers fail OPEN on an open breaker (record the check as unrun) — sovereignty:
|
||||
// an unreachable advisory feed never blocks a patch, same as today's transport
|
||||
// errors.
|
||||
var osvBreaker = circuitbreaker.New("osv", circuitbreaker.Config{
|
||||
FailureThreshold: 5,
|
||||
FailureWindow: 60 * time.Second,
|
||||
OpenDuration: 30 * time.Second,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
// OSVBreakerStats exposes the OSV breaker state for /health/tasks (OBS-001).
|
||||
func OSVBreakerStats() circuitbreaker.Stats { return osvBreaker.GetStats() }
|
||||
|
||||
// OSVRecheckInterval is how long a stored supply-chain result is treated as
|
||||
// fresh. The scan path skips packages checked more recently than this so a
|
||||
// frequent scan cadence does not re-hit OSV.dev for every package every cycle;
|
||||
|
|
@ -244,23 +262,27 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
|
|||
return
|
||||
}
|
||||
|
||||
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/querybatch", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [supply_chain] batch_query_failed count=%d error=%v", len(reqs), err)
|
||||
recordBatchFailure(reqs, store)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[WARNING] [supply_chain] batch_query_status=%d count=%d", resp.StatusCode, len(reqs))
|
||||
recordBatchFailure(reqs, store)
|
||||
return
|
||||
}
|
||||
|
||||
// Breaker-wrapped (SCALE-001 S8): a down/slow OSV trips the breaker so the
|
||||
// rest of this batch — and the single-query path — fail fast rather than each
|
||||
// blocking on a 30s timeout. An open breaker takes the same fail-open path as
|
||||
// a transport error below.
|
||||
var batchResp osvBatchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
|
||||
log.Printf("[WARNING] [supply_chain] batch_decode_failed count=%d error=%v", len(reqs), err)
|
||||
callErr := osvBreaker.Call(func() error {
|
||||
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/querybatch", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("osv batch status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if callErr != nil {
|
||||
log.Printf("[WARNING] [supply_chain] batch_query_failed count=%d error=%v", len(reqs), callErr)
|
||||
recordBatchFailure(reqs, store)
|
||||
return
|
||||
}
|
||||
|
|
@ -429,16 +451,26 @@ func CheckOSVVulnerabilities(pkgName, ecosystem, version string) *SupplyChainChe
|
|||
return nil
|
||||
}
|
||||
|
||||
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/query", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [supply_chain] query_failed pkg=%s error=%v", pkgName, err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Breaker-wrapped (SCALE-001 S8); shares osvBreaker with the batch path. An
|
||||
// open breaker returns nil (fail-open) — same as a transport error: the check
|
||||
// is recorded as unrun, never blocking a patch (sovereignty).
|
||||
var result OSVQueryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
log.Printf("[WARNING] [supply_chain] decode_failed pkg=%s error=%v", pkgName, err)
|
||||
callErr := osvBreaker.Call(func() error {
|
||||
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/query", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("osv status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if callErr != nil {
|
||||
log.Printf("[WARNING] [supply_chain] query_failed pkg=%s error=%v", pkgName, callErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,25 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
)
|
||||
|
||||
// repologyBreaker wraps repology.org fetches (SCALE-001 S8). Repology is
|
||||
// best-effort alias enrichment; when it's down or slow the breaker opens and
|
||||
// Refresh fails fast instead of every reconcile cycle blocking on it. A 404
|
||||
// (project not found) does not count against the breaker — that's a per-slug
|
||||
// data condition, not a sign repology is unhealthy.
|
||||
var repologyBreaker = circuitbreaker.New("repology", circuitbreaker.Config{
|
||||
FailureThreshold: 5,
|
||||
FailureWindow: 60 * time.Second,
|
||||
OpenDuration: 30 * time.Second,
|
||||
HalfOpenAttempts: 2,
|
||||
})
|
||||
|
||||
// RepologyBreakerStats exposes the repology breaker state for /health/tasks (OBS-001).
|
||||
func RepologyBreakerStats() circuitbreaker.Stats { return repologyBreaker.GetStats() }
|
||||
|
||||
// RepologyCache wraps the Repology /packages endpoint to build a per-ecosystem
|
||||
// alias map. The reconciler uses these aliases to match agent-reported package
|
||||
// names to tracked_software entries.
|
||||
|
|
@ -112,29 +128,44 @@ type repologyPackageEntry struct {
|
|||
// goroutines from reading cached state while we wait.
|
||||
func (rc *RepologyCache) Refresh(ctx context.Context, slug string) error {
|
||||
endpoint := fmt.Sprintf("https://repology.org/api/v1/project/%s/packages", url.PathEscape(strings.ToLower(slug)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repology_cache: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repology_cache: fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("repology_cache: project %q not found", slug)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("repology_cache: status %d for %q", resp.StatusCode, slug)
|
||||
}
|
||||
|
||||
// Breaker-wrapped network fetch (SCALE-001 S8). DB upserts + the politeness
|
||||
// sleep stay outside the breaker. A 404 is a per-slug data condition, not a
|
||||
// repology-health failure, so it returns the not-found error to the caller
|
||||
// without tripping the breaker.
|
||||
var entries []repologyPackageEntry
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
return fmt.Errorf("repology_cache: decode: %w", err)
|
||||
var notFound bool
|
||||
callErr := repologyBreaker.Call(func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
notFound = true
|
||||
return nil // repology is up; the project just doesn't exist
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if callErr != nil {
|
||||
return fmt.Errorf("repology_cache: %w", callErr)
|
||||
}
|
||||
if notFound {
|
||||
return fmt.Errorf("repology_cache: project %q not found", slug)
|
||||
}
|
||||
|
||||
ecoPkgs := make(map[string]map[string]bool)
|
||||
|
|
|
|||
Loading…
Reference in a new issue