Repository navigation
Don't count a 4xx answer as a registry failure in the breaker #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
andrew
merged 4 commits into
git-pkgs:main
from
cleygraf:upstream/client-errors-do-not-trip-breaker
Nov 7, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
13f4eed
Don't count a 4xx answer as a registry failure in the breaker
d4bb933
Address review: Head sentinels, StatusError content type, test counters
00a6aa9
Exempt every StatusError from the breaker and cover 304
f5e809f
Simplify StatusError check in countsAgainstBreaker
andrew File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,290 @@ | ||
| package fetch | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "sync/atomic" | ||
| "testing" | ||
| ) | ||
|
|
||
| // A 4xx answer is the registry replying, not failing, so the breaker must stay | ||
| // closed however many of them arrive, and every answer must reach the caller. | ||
| func TestCircuitBreakerClientErrorsDoNotTrip(t *testing.T) { | ||
| const body = "denied by policy" | ||
| const contentType = "application/json" | ||
|
|
||
| for _, code := range []int{ | ||
| http.StatusBadRequest, | ||
| http.StatusUnauthorized, | ||
| http.StatusForbidden, | ||
| http.StatusConflict, | ||
| http.StatusGone, | ||
| } { | ||
| t.Run(http.StatusText(code), func(t *testing.T) { | ||
| var hits atomic.Int32 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| hits.Add(1) | ||
| w.Header().Set("Content-Type", contentType) | ||
| w.WriteHeader(code) | ||
| _, _ = w.Write([]byte(body)) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
|
|
||
| // Well past the threshold. | ||
| const attempts = 20 | ||
| for range attempts { | ||
| _, err := cbFetcher.Fetch(context.Background(), server.URL+"/artifact.tar.gz") | ||
| var status *StatusError | ||
| if !errors.As(err, &status) { | ||
| t.Fatalf("want a StatusError passed through, got %v", err) | ||
| } | ||
| if status.Code != code || status.Body != body { | ||
| t.Fatalf("got status %d body %q, want %d body %q", status.Code, status.Body, code, body) | ||
| } | ||
| if status.ContentType != contentType { | ||
| t.Fatalf("got content type %q, want %q", status.ContentType, contentType) | ||
| } | ||
| } | ||
|
|
||
| if got := hits.Load(); got != attempts { | ||
| t.Errorf("breaker swallowed requests: want %d upstream hits, got %d", attempts, got) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "closed" { | ||
| t.Errorf("breaker for %s is %s, want closed", registry, state) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| // A 304 answers a conditional request, so it is the registry answering too. | ||
| // The server sends neither a body nor a Content-Type with it. | ||
| t.Run(http.StatusText(http.StatusNotModified), func(t *testing.T) { | ||
| var hits atomic.Int32 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| hits.Add(1) | ||
| w.WriteHeader(http.StatusNotModified) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
| headers := http.Header{"If-None-Match": []string{`"etag"`}} | ||
|
|
||
| const attempts = 20 | ||
| for range attempts { | ||
| _, err := cbFetcher.FetchWithHeaders(context.Background(), server.URL+"/artifact.tar.gz", headers) | ||
| var status *StatusError | ||
| if !errors.As(err, &status) { | ||
| t.Fatalf("want a StatusError passed through, got %v", err) | ||
| } | ||
| if status.Code != http.StatusNotModified || status.Body != "" || status.ContentType != "" { | ||
| t.Fatalf("got status %d body %q content type %q, want 304 without body or content type", | ||
| status.Code, status.Body, status.ContentType) | ||
| } | ||
| } | ||
|
|
||
| if got := hits.Load(); got != attempts { | ||
| t.Errorf("breaker swallowed requests: want %d upstream hits, got %d", attempts, got) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "closed" { | ||
| t.Errorf("breaker for %s is %s, want closed", registry, state) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| // The observed fetch path shares the predicate, so a repeated 4xx must reach | ||
| // the caller there as well and leave the breaker closed. | ||
| func TestCircuitBreakerObservedClientErrorsDoNotTrip(t *testing.T) { | ||
| var hits atomic.Int32 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| hits.Add(1) | ||
| w.WriteHeader(http.StatusForbidden) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
|
|
||
| const attempts = 20 | ||
| for range attempts { | ||
| _, err := cbFetcher.FetchObservedWithHeaders(context.Background(), server.URL+"/artifact.tar.gz", nil) | ||
| var status *StatusError | ||
| if !errors.As(err, &status) || status.Code != http.StatusForbidden { | ||
| t.Fatalf("want a 403 StatusError passed through, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| if got := hits.Load(); got != attempts { | ||
| t.Errorf("breaker swallowed requests: want %d upstream hits, got %d", attempts, got) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "closed" { | ||
| t.Errorf("breaker for %s is %s, want closed", registry, state) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestCircuitBreakerHeadClientErrorDoesNotTrip(t *testing.T) { | ||
| var hits atomic.Int32 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| hits.Add(1) | ||
| w.WriteHeader(http.StatusForbidden) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
|
|
||
| const attempts = 20 | ||
| for range attempts { | ||
| _, _, err := cbFetcher.Head(context.Background(), server.URL+"/artifact.tar.gz") | ||
| var status *StatusError | ||
| if !errors.As(err, &status) || status.Code != http.StatusForbidden { | ||
| t.Fatalf("want a 403 StatusError passed through, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| if got := hits.Load(); got != attempts { | ||
| t.Errorf("breaker swallowed requests: want %d upstream hits, got %d", attempts, got) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "closed" { | ||
| t.Errorf("breaker for %s is %s, want closed", registry, state) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // A registry that cannot answer still trips the breaker, so later requests are | ||
| // short-circuited instead of hammering it. | ||
| func TestCircuitBreakerServerErrorsStillTrip(t *testing.T) { | ||
| var hits atomic.Int32 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| hits.Add(1) | ||
| w.WriteHeader(http.StatusServiceUnavailable) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
|
|
||
| const attempts = 20 | ||
| for range attempts { | ||
| if _, err := cbFetcher.Fetch(context.Background(), server.URL+"/artifact.tar.gz"); !errors.Is(err, ErrUpstreamDown) { | ||
| t.Fatalf("want ErrUpstreamDown, got %v", err) | ||
| } | ||
| } | ||
|
|
||
| if got := hits.Load(); got >= attempts { | ||
| t.Errorf("breaker never opened: %d upstream hits for %d requests", got, attempts) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "open" { | ||
| t.Errorf("breaker for %s is %s, want open", registry, state) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Head reports the statuses that mean "cannot answer" with the same sentinels | ||
| // as Fetch, so a rate limit or a server error still counts against the breaker. | ||
| func TestCircuitBreakerHeadUnavailableStillTrips(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| code int | ||
| want error | ||
| }{ | ||
| {name: "rate limited", code: http.StatusTooManyRequests, want: ErrRateLimited}, | ||
| {name: "server error", code: http.StatusServiceUnavailable, want: ErrUpstreamDown}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| var hits atomic.Int32 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| hits.Add(1) | ||
| w.WriteHeader(tt.code) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
|
|
||
| if _, _, err := cbFetcher.Head(context.Background(), server.URL+"/artifact.tar.gz"); !errors.Is(err, tt.want) { | ||
| t.Fatalf("first Head = %v, want %v", err, tt.want) | ||
| } | ||
|
|
||
| // Well past the threshold. Once the breaker is open the answer is | ||
| // ErrUpstreamDown for both statuses. | ||
| const attempts = 20 | ||
| for range attempts { | ||
| _, _, err := cbFetcher.Head(context.Background(), server.URL+"/artifact.tar.gz") | ||
| if !errors.Is(err, tt.want) && !errors.Is(err, ErrUpstreamDown) { | ||
| t.Fatalf("Head = %v, want %v or ErrUpstreamDown once the breaker is open", err, tt.want) | ||
| } | ||
| } | ||
|
|
||
| if got := hits.Load(); got >= attempts { | ||
| t.Errorf("breaker never opened: %d upstream hits for %d requests", got, attempts) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "open" { | ||
| t.Errorf("breaker for %s is %s, want open", registry, state) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestCircuitBreakerTransportErrorsStillTrip(t *testing.T) { | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) | ||
| deadURL := server.URL + "/artifact.tar.gz" | ||
| server.Close() | ||
|
|
||
| cbFetcher := NewCircuitBreakerFetcher(NewFetcher(WithMaxRetries(0), WithBaseDelay(0))) | ||
|
|
||
| const attempts = 20 | ||
| for range attempts { | ||
| if _, err := cbFetcher.Fetch(context.Background(), deadURL); err == nil { | ||
| t.Fatal("want an error from a registry that cannot be reached, got nil") | ||
| } | ||
| } | ||
|
|
||
| // Open now, so the next request fails without dialling. | ||
| if _, err := cbFetcher.Fetch(context.Background(), deadURL); !errors.Is(err, ErrUpstreamDown) { | ||
| t.Errorf("want ErrUpstreamDown once the breaker is open, got %v", err) | ||
| } | ||
| for registry, state := range cbFetcher.GetBreakerState() { | ||
| if state != "open" { | ||
| t.Errorf("breaker for %s is %s, want open", registry, state) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Callers that read the status out of the message keep working. | ||
| func TestStatusErrorMessage(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| err *StatusError | ||
| want string | ||
| }{ | ||
| { | ||
| name: "fetch keeps the status and body", | ||
| err: &StatusError{Code: http.StatusForbidden, Body: "denied by policy"}, | ||
| want: "unexpected status 403: denied by policy", | ||
| }, | ||
| { | ||
| name: "head has no body", | ||
| err: &StatusError{Code: http.StatusForbidden}, | ||
| want: "unexpected status 403", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if got := tt.err.Error(); got != tt.want { | ||
| t.Errorf("message = %q, want %q", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } |
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct the list of responses that count as breaker failures.
If a caller sends
If-None-MatchthroughFetchWithHeaders, the registry can return304 Not Modified. The fetcher returnsStatusError{Code: 304}, andcountsAgainstBreakercounts it as a failure. Repeated 304 responses can open the host-wide breaker. The statement that only transport errors, rate limiting, and 5xx responses count is therefore incorrect. Include unhandled statuses such as 304 in this description, or change the classification to match the stated rule. (raw.githubusercontent.com)