Let a tripped circuit breaker keep retrying instead of latching open - #70
Open
wickedOne wants to merge 1 commit into
Open
Let a tripped circuit breaker keep retrying instead of latching open#70wickedOne wants to merge 1 commit into
wickedOne wants to merge 1 commit into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the per-registry circuit breaker behavior in fetch so that a breaker does not permanently “latch open” after long outages, and so “breaker open” errors are consistently reported to callers.
Changes:
- Configure the custom exponential backoff used by
CircuitBreakerFetcherto retry indefinitely (MaxElapsedTime = 0) and to share the same clock as the breaker. - Remove redundant
Ready()pre-checks soCall()alone governs probe admission, avoiding accidental probe “spending”. - Normalize open-breaker errors via
breakerError()so callers consistently seeErrUpstreamDown.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| README.md | Updates circuit breaker documentation to reflect rolling-window trip behavior and indefinite probing/retry semantics. |
| go.mod | Promotes github.com/facebookgo/clock to a direct dependency (used for breaker/backoff time source). |
| fetch/circuit_breaker.go | Implements indefinite backoff, unified clock usage, removes redundant Ready() checks, and maps breaker-open errors to ErrUpstreamDown. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+74
to
+80
| // Retry forever, which is what the breaker library itself defaults to. | ||
| // NewExponentialBackOff instead defaults MaxElapsedTime to 15 minutes, after | ||
| // which NextBackOff returns backoff.Stop and the breaker never half-opens | ||
| // again. Only a success resets the backoff, and the breaker no longer lets | ||
| // one through, so an outage lasting longer than MaxElapsedTime leaves the | ||
| // breaker open for the life of the process even after the registry recovers. | ||
| expBackoff.MaxElapsedTime = 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix for the problem mentioned in the Notes section of git-pkgs/proxy#275
Problem
CircuitBreakerFetcher.getBreakerbuilds its ownbackoff.ExponentialBackOffand sets
InitialInterval,MaxIntervalandMultiplier, but leavesMaxElapsedTimeat cenk/backoff'sDefaultMaxElapsedTimeof 15 minutes.Once the elapsed time passes that,
NextBackOff()returnsbackoff.Stop, andrubyist/circuitbreaker's
state()only half-opensif cb.nextBackOff != backoff.Stop && since > cb.nextBackOff. The breaker therefore stops admittingprobes entirely. The one thing that would clear it is
Success(), the solecaller of
BackOff.Reset()— and a success is unreachable while no call getsthrough. So any outage lasting longer than 15 minutes leaves that host's breaker
open for the life of the process, long after the registry has recovered. Only a
restart clears it.
The breaker library itself does not have this problem:
NewBreakerWithOptionssets
MaxElapsedTimetodefaultBackoffMaxElapsedTime, which is 0, on thebackoff it constructs when no
BackOffoption is given. Supplying a custombackoff is what silently opts into the 15 minute cut-off.
This was found in production. A proxy built on this package served npm metadata
normally while every uncached tarball returned 502 in about 0.2s with
circuit breaker open for registry [registry.npmjs.org](http://registry.npmjs.org/), for hours afterregistry.npmjs.org was healthy again. Metadata does not go through the fetcher,
so only artifact downloads for that one host were affected, which made it look
like an npm-specific outage rather than latched local state.
A second defect made recovery slower and noisier than intended. Each fetch method
called
breaker.Ready()as a pre-check and thenbreaker.Call(), which checksthe breaker again. A
Ready()that observes half-open advances the backoff andclears the half-open flag, so the check inside
Call()re-tested against thealready-advanced interval and usually lost — spending the probe the call was
about to make. Measured against a dead upstream over 20 one-minute steps, only
3 requests actually reached it. Those refusals also returned the library's bare
circuit.ErrBreakerOpen, which does not wrapErrUpstreamDown, so callersbranching on
errors.Is(err, ErrUpstreamDown)— includingfetch/fetcher.go— did not recognise them.Change
expBackoff.MaxElapsedTime = 0ingetBreaker, matching the library's owndefault, so a tripped breaker keeps admitting one probe per backoff interval
for as long as the registry stays down and closes as soon as one succeeds.
Backoff growth is unchanged: 30s initial, doubling, capped at 5 minutes.
Ready()pre-check fromFetchWithHeaders,FetchObservedWithHeadersandHead. The same measurement now shows 6 probesover the same 20 steps, at the intervals the backoff actually specifies.
breakerError, which mapscircuit.ErrBreakerOpenonto the wrappedErrUpstreamDownand passes fetch errors through untouched, so every refusalto contact a registry reports the same way to callers.
expBackoff.Clockis now set to the same clock the breaker uses. Previouslythe breaker read
circuit.Options.Clockwhile its backoff readbackoff.SystemClock, so the two measured time from independent sources.clockfield onCircuitBreakerFetchersupplies that clock,nil meaning
clock.New(). It exists so the regression above can be tested:reproducing it requires pushing the backoff's elapsed time past 15 minutes,
which is not something a test should wait for, and both clocks have to advance
together for the reproduction to be faithful. It does not reach the breaker's
failure-count window, which the library keeps on the system clock. No public
API changes.
[github.com/facebookgo/clock](http://github.com/facebookgo/clock%60) moves from indirect to direct ingo.mod. Itwas already in the module graph as a dependency of rubyist/circuitbreaker;
go.sumis unchanged.Testing
TestCircuitBreakerRecoversAfterProlongedOutagetrips a breaker against aserver returning 503, then advances a mock clock through an hour of failing
probes in 10 minute steps — each step longer than the 5 minute maximum interval,
so every step admits exactly one probe and the sequence is deterministic. The
server then recovers and the test asserts the next fetch succeeds and
GetBreakerStatereportsclosed.Without the one-line backoff fix it fails with the production symptom:
TestCircuitBreakerProbesOncePerBackoffIntervalcovers the second defect: aftereach interval elapses, exactly one request reaches the registry, a second call
in the same interval reaches it zero times, and both errors wrap
ErrUpstreamDown.go build ./...,go vet ./...andgo test ./...pass; the breaker tests alsopass under
-race;golangci-lint run ./fetch/...reports 0 issues.Docs
The README circuit breaker section now describes the trip condition accurately —
5 failures inside the breaker's rolling 10 second failure window, which is what
ThresholdTripFuncmeasures, rather than 5 consecutive failures — and statesthat one request per backoff interval is let through as a probe while the rest
fail with
ErrUpstreamDownwithout contacting the registry, and that retriesnever give up, so a breaker recovers however long the registry was down. The
matching comment in
getBreakeris corrected the same way.Downstream
Consumers pick this up on their next dependency bump. Until then, a latched
breaker still needs a process restart to clear.