Report upstream circuit breaker state in /health and /metrics - #275
Report upstream circuit breaker state in /health and /metrics#275wickedOne wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds observability for per-upstream artifact-fetch circuit breakers by surfacing breaker state via /health, exporting Prometheus metrics on /metrics, and emitting transition logs when breakers open/close.
Changes:
- Introduces a
breakerMonitorthat snapshotsCircuitBreakerFetcherbreaker state, updates Prometheus metrics, and logs open/close transitions. - Extends
/health(HealthResponse) to optionally include acircuit_breakersmap (host →"open"/"closed"), without affecting readiness status. - Updates documentation and Swagger outputs to describe the new
/healthfield and the two new metrics.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents breaker state in /health and the new Prometheus metrics + semantics. |
| internal/server/server.go | Wires breakerMonitor into server startup, /metrics scrape path, and /health response. |
| internal/server/server_test.go | Exposes the constructed *Server from the test harness to allow injecting a breaker monitor. |
| internal/server/health.go | Adds CircuitBreakers field to HealthResponse with omitempty JSON tag and explanatory comment. |
| internal/server/breakers.go | New monitor implementation: snapshots breaker state, updates metrics, and logs transitions. |
| internal/server/breakers_test.go | New unit tests covering state reporting, metric series behavior, transitions, and /health output. |
| docs/swagger/swagger.json | Regenerates Swagger to include circuit_breakers in the health schema. |
| docs/swagger/docs.go | Regenerates embedded Swagger template to include circuit_breakers. |
| docs/architecture.md | Updates architecture docs to mention breaker state in /health and metrics. |
Files not reviewed (1)
- docs/swagger/docs.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
andrew
left a comment
There was a problem hiding this comment.
Thanks, this fills a real gap and the design calls (open breaker keeps /health 200, series only for tripped hosts) both look right to me.
One change:
internal/server/breakers.go — m.source.GetBreakerState() is read before m.mu.Lock(). Two concurrent snapshot() calls (/health and a /metrics scrape landing during a transition) can interleave so an older closed read is applied to seen after a newer open one: one real trip becomes two in the counter, plus a spurious "circuit breaker closed" log and the gauge briefly at 0 while the breaker is actually open. GetBreakerState() is just a map copy, so moving it under the lock removes the ordering hazard at no cost. -race won't flag this since it's a logical ordering issue rather than a data race.
On the two open points:
/healthexposing upstream hostnames:/metricson the same unauthenticated port already carries the same hostnames in theregistrylabel as of this change, so/healthadds nothing new. Restricting both at the ingress is the answer for deployments that care; no change needed here.- The
MaxElapsedTimelatching ingit-pkgs/registriesis real and there's no issue for it yet. I'll open one there; nothing to do in this PR.
|
Correction on the last point: there is already a fix open at git-pkgs/registries#70, so no separate issue needed. |
* Bump go tool golangci-lint to v2.13.1 The .golangci.yml goconst.ignore-tests setting was added in v2.12.0 (golangci/golangci-lint#6480). On the previously pinned v2.10.1, config verify fails with "additional properties 'ignore-tests' not allowed" and the setting is silently ignored at run time, so goconst counts test-file literals toward min-occurrences. * Apply gofmt and CutSuffix simplification - gofmt -w internal/server/health_test.go - Replace HasSuffix+TrimSuffix with CutSuffix in ParseSize * Remove dead code and migrate tests off legacy Filesystem storage Migrate the three test call sites of storage.NewFilesystem to storage.OpenBucket("file://...") and drop the deprecated StorageConfig.Path field from test configs, then delete code that deadcode reports as unreachable from cmd/proxy: - internal/storage/filesystem.go and its tests - storage.HashingReader - enrichment.Service.BulkCheckVulnerabilities and NormalizeLicense - server.ActiveRequestsMiddleware (no-op body; the real tracking is the inline r.Use at server.go:226) - mirror.RegistrySource (unimplemented stub) metrics.UpdateCircuitBreakerState and RecordCircuitBreakerTrip are kept because #275 wires them. Update the CONTRIBUTING.md storage section to reflect blob.go.
Problem
The artifact fetcher wraps every upstream in a per-host circuit breaker, but its state was invisible from outside the process.
metrics.UpdateCircuitBreakerStateandmetrics.RecordCircuitBreakerTripexisted and were never called outside their own unit test, andCircuitBreakerFetcher.GetBreakerState()was never consulted.That made an open breaker hard to diagnose, because everything around it still looks healthy:
Proxy.HTTPClient, not the fetcher, so no breaker applies),/healthreports database and storage asok.Only uncached artifact downloads for that one host fail, with HTTP 502 and no request reaching the upstream. We hit this on an npm-only failure: every
*.tgzreturned502 {"error":"failed to fetch package"}in ~0.2s while packuments returned 200 and cargo and gem downloads were unaffected. Identifying the breaker as the cause meant reading pod logs forcircuit breaker open for registry [registry.npmjs.org](http://registry.npmjs.org/).Change
Adds a
breakerMonitorthat readsGetBreakerState()and publishes it to three places:/health: a newcircuit_breakersobject mapping upstream host to"open"/"closed", omitted while no breaker exists./metrics: populatesproxy_circuit_breaker_state(0 closed, 2 open) andproxy_circuit_breaker_trips_total, refreshed on each scrape. Trips are counted on observed closed→open transitions, since the fetcher exposes currentstate rather than trip events.
ERRORwhen a breaker opens and oneINFOwhen it closes, instead of a per-request error that gives no indication the breaker is the cause.Two deliberate choices:
An open breaker leaves
statusas"ok"and the HTTP status code unchanged. It describes an upstream refusing to serve, not this proxy being unfit for traffic, and/healthis documented as a readiness probe: failing it over one bad upstream would pull the pod from rotation for every other ecosystem. Alerting belongs on the gauge.Metric series are published only for registries that have tripped at least once. A breaker is created per host the proxy fetches artifacts from, and for some ecosystems that host comes from upstream metadata rather than configuration (composer takes it from a package's
dist.url, helm from the chart URLs inindex.yaml), so publishing every host would let upstream content grow the series count for the life of the process. A host that has never tripped carries no information a series could convey; once it trips it keeps reporting, so recovery is still visible as a transition to 0 rather than as a series thatvanishes.
/healthis a per-request response rather than a persistent series and lists every breaker either way.The monitor takes a one-method
breakerStateSourceinterface rather than*fetch.CircuitBreakerFetcherdirectly, so trip and recovery transitions can be tested without waiting out a 30-second backoff.Notes
This is observability only. It does not change breaker behaviour, and in particular does not fix the upstream cause of a breaker that never recovers:
git-pkgs/registries'CircuitBreakerFetcher.getBreakersupplies a custombackoff.ExponentialBackOffwithout settingMaxElapsedTime, so it keeps the cenk/backoff default of 15 minutes. OnceNextBackOff()returnsbackoff.Stop,rubyist/circuitbreaker'sstate()never returns half-open again and the breaker stays open for the life of the process even after the upstream recovers. That needs a one-line fix inregistries(expBackoff.MaxElapsedTime = 0, matching the library's own default); until then a restart is the only way to clear a latched breaker, which this MR documents.Worth a second opinion:
/healthis unauthenticated on the same port package clients use, and now returns upstream hostnames. For deployments pointingupstream.*at internal mirrors, those hostnames become readable by anything that can reach the proxy. Exposing the host is the point of the field, so the alternative is restricting/healthand/metricsat the ingress.Testing
/healthbut publishing no series; recovery after a trip reported as 0 with the series retained; a re-trip counted separately; nil-safe monitor;/healthincludes an open breaker while staying 200; andcircuit_breakersomitted when no breaker exists. Metric assertions collect from the registry rather than callingWithLabelValues, which would create the series under test.upstream.npmpointed at a dead port plus one successful crates.io download:/healthreported{"127.0.0.1:19999":"open","[static.crates.io](http://static.crates.io/)":"closed"}while/metricscarried series for the tripped host only, with state 2 and one trip, and exactly one transition log line.go test ./...andgolangci-lint run ./internal/server/...clean;go test -race -count=2 ./internal/server -run 'Breaker|Health'clean.go generate ./internal/server.Docs
README.md: both metrics added to the metrics table; refresh, trip-counting and series-publishing semantics; alerting guidance including what does and does not keep working while a breaker is open;circuit_breakersin the/healthsample and prose; and why an open breaker does not fail the probe.docs/architecture.md: the/healthbullet covers the new field.